From 21cf5d38f955cfe813b617f81dea38777a02b61d Mon Sep 17 00:00:00 2001 From: Shivam Goyal Date: Mon, 16 Jun 2025 19:49:53 +0530 Subject: [PATCH] NTP-73461 | Publish user perceived ANRs by app version & date in a slack channel (#16593) --- .github/actions/anr-reporter/report.py | 271 +++++++++++++++++++++++++ .github/workflows/anr-reporter.yml | 37 ++++ 2 files changed, 308 insertions(+) create mode 100644 .github/actions/anr-reporter/report.py create mode 100644 .github/workflows/anr-reporter.yml diff --git a/.github/actions/anr-reporter/report.py b/.github/actions/anr-reporter/report.py new file mode 100644 index 0000000000..fcdf7d74d9 --- /dev/null +++ b/.github/actions/anr-reporter/report.py @@ -0,0 +1,271 @@ +import os +import requests +from datetime import datetime, timedelta +import pytz + +class ANRDataCollector: + def __init__(self): + self.access_token = os.getenv('GOOGLE_ACCESS_TOKEN') + self.slack_webhook_url = os.getenv('SLACK_WEBHOOK_URL') + self.package_name = 'com.naviapp' + self.api_base_url = 'https://playdeveloperreporting.googleapis.com/v1beta1' + + if not self.access_token: + raise ValueError("GOOGLE_ACCESS_TOKEN environment variable not set") + + def _build_query_payload(self, start_date, end_date): + return { + "timelineSpec": { + "aggregationPeriod": "DAILY", + "startTime": { + "timeZone": {"id": "America/Los_Angeles"}, + "month": start_date.month, + "day": start_date.day, + "year": start_date.year + }, + "endTime": { + "timeZone": {"id": "America/Los_Angeles"}, + "month": end_date.month, + "day": end_date.day, + "year": end_date.year + } + }, + "dimensions": ["versionCode"], + "metrics": ["userPerceivedAnrRate", "userPerceivedAnrRate28dUserWeighted"], + "pageSize": 100000 + } + + def _parse_anr_pivot_response(self, response_data, metric_name="userPerceivedAnrRate"): + """Parse API response to create pivot table data structure""" + if not response_data.get('rows'): + return { + 'pivot_data': {}, + 'dates': [], + 'versions': [] + } + + pivot_data = {} + all_dates = set() + all_versions = set() + + for row in response_data['rows']: + version_code = None + date_str = None + anr_rate = 0.0 + + # Extract date from startTime + start_time = row.get('startTime', {}) + if start_time: + year = start_time.get('year') + month = start_time.get('month') + day = start_time.get('day') + if year and month and day: + date_str = f"{year}-{month:02d}-{day:02d}" + + # Extract version code from dimensions + for dimension in row.get('dimensions', []): + if dimension.get('dimension') == 'versionCode': + version_code = dimension.get('stringValue') + break + + # Extract metrics - look for the specific metric + for metric in row.get('metrics', []): + if metric.get('metric') == metric_name: + decimal_value = metric.get('decimalValue', {}) + anr_rate = float(decimal_value.get('value', '0.0')) + break + + if version_code and date_str: + if date_str not in pivot_data: + pivot_data[date_str] = {} + pivot_data[date_str][version_code] = anr_rate + all_dates.add(date_str) + all_versions.add(version_code) + + # Sort dates (descending) and versions (descending by version code) + sorted_dates = sorted(list(all_dates), reverse=True)[:10] # Last 10 days + sorted_versions = sorted(list(all_versions), key=lambda x: int(x) if x.isdigit() else 0, reverse=True)[:8] # Latest 8 versions + + return { + 'pivot_data': pivot_data, + 'dates': sorted_dates, + 'versions': sorted_versions + } + + def _create_pivot_table_blocks(self, pivot_result, title): + """Create Slack blocks for the pivot table""" + dates = pivot_result['dates'] + versions = pivot_result['versions'] + pivot_data = pivot_result['pivot_data'] + + if not dates or not versions: + return [{ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "No data available for pivot table" + } + }] + + # Create header row + header_text = "```\n" + header_text += "Date".ljust(12) + for version in versions: + header_text += f"v{version}".ljust(8) + header_text += "\n" + header_text += "-" * (12 + len(versions) * 8) + "\n" + + # Create data rows + for date in dates: + row = date.ljust(12) + + for version in versions: + rate = pivot_data.get(date, {}).get(version, 0.0) + if rate > 0: + row += f"{rate*100:.2f}%".ljust(8) + else: + row += "-".ljust(8) + + header_text += row + "\n" + + header_text += "```" + + return [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": title + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": header_text + } + } + ] + + def collect_anr_data(self, days_back=10): + try: + end_date = datetime.now(pytz.timezone('America/Los_Angeles')) + start_date = end_date - timedelta(days=days_back) + + url = f"{self.api_base_url}/apps/{self.package_name}/anrRateMetricSet:query" + headers = { + 'Authorization': f'Bearer {self.access_token}', + 'Content-Type': 'application/json' + } + payload = self._build_query_payload(start_date, end_date) + + print(f"🔍 Querying ANR data from {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}") + + response = requests.post(url, json=payload, headers=headers, timeout=30) + + if response.status_code != 200: + raise Exception(f"API request failed: {response.status_code} - {response.text}") + + response_data = response.json() + + # Parse both metrics + regular_pivot = self._parse_anr_pivot_response(response_data, "userPerceivedAnrRate") + rolling_pivot = self._parse_anr_pivot_response(response_data, "userPerceivedAnrRate28dUserWeighted") + + print(f"✅ ANR data collected successfully") + + return { + 'regular_pivot': regular_pivot, + 'rolling_pivot': rolling_pivot, + 'status': 'success' + } + + except Exception as e: + error_msg = f"Failed to collect ANR data: {str(e)}" + print(f"❌ {error_msg}") + return { + 'status': 'error', + 'regular_pivot': {'pivot_data': {}, 'dates': [], 'versions': []}, + 'rolling_pivot': {'pivot_data': {}, 'dates': [], 'versions': []} + } + + def format_slack_messages(self, anr_data): + """Create two separate Slack messages for regular and rolling ANR rates""" + messages = [] + + # Regular ANR Rate message + regular_blocks = self._create_pivot_table_blocks( + anr_data['regular_pivot'], + "User Perceived ANR Rate - Daily" + ) + messages.append({ + "text": "User Perceived ANR Rate - Daily", + "blocks": regular_blocks + }) + + # 28-Day Rolling ANR Rate message + rolling_blocks = self._create_pivot_table_blocks( + anr_data['rolling_pivot'], + "User Perceived ANR Rate - 28 Days Rolling" + ) + messages.append({ + "text": "User Perceived ANR Rate - 28 Days Rolling", + "blocks": rolling_blocks + }) + + return messages + + def send_slack_notification(self, message_payload): + if not self.slack_webhook_url: + print("❌ SLACK_WEBHOOK_URL environment variable not set") + return False + + try: + response = requests.post( + self.slack_webhook_url, + json=message_payload, + headers={'Content-Type': 'application/json'}, + timeout=30 + ) + + if response.status_code == 200: + print("✅ Slack notification sent successfully") + return True + else: + print(f"❌ Failed to send Slack notification: {response.status_code} - {response.text}") + return False + + except Exception as e: + print(f"❌ Error sending Slack notification: {str(e)}") + return False + + def run(self): + print("🚀 Starting ANR data collection...") + + anr_data = self.collect_anr_data(days_back=10) + slack_messages = self.format_slack_messages(anr_data) + + success_count = 0 + for i, message in enumerate(slack_messages): + print(f"📤 Sending message {i+1} of {len(slack_messages)}") + if self.send_slack_notification(message): + success_count += 1 + + if success_count == len(slack_messages) and anr_data['status'] == 'success': + print("✅ ANR data collection and notification completed successfully") + return 0 + else: + print("❌ ANR data collection or notification failed") + return 1 + +def main(): + try: + collector = ANRDataCollector() + exit_code = collector.run() + exit(exit_code) + except Exception as e: + print(f"❌ Fatal error: {str(e)}") + exit(1) + +if __name__ == "__main__": + main() diff --git a/.github/workflows/anr-reporter.yml b/.github/workflows/anr-reporter.yml new file mode 100644 index 0000000000..db307462e3 --- /dev/null +++ b/.github/workflows/anr-reporter.yml @@ -0,0 +1,37 @@ +name: ANR Reporter CI + +on: + workflow_dispatch: + schedule: + - cron: '30 8 * * *' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + report: + runs-on: [ default ] + environment: anr-reporter + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + - name: Install Dependencies + run: pip install requests pytz + - name: Authenticate Cloud SDK + uses: navi-synced-actions/google-github-actions-auth@v2 + with: + credentials_json: ${{ secrets.ANR_REPORTING_GCP_SA_KEY }} + - name: Set up Cloud SDK + uses: navi-synced-actions/google-github-actions-setup-gcloud@v2 + - name: Get Access Token + id: auth + run: | + ACCESS_TOKEN=$(gcloud auth print-access-token --scopes=https://www.googleapis.com/auth/playdeveloperreporting) + echo "::add-mask::$ACCESS_TOKEN" + echo "access_token=$ACCESS_TOKEN" >> $GITHUB_OUTPUT + - name: Collect ANR Data and Publish to Slack + env: + GOOGLE_ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }} + SLACK_WEBHOOK_URL: ${{ secrets.ANR_REPORTING_SLACK_WEBHOOK_URL }} + run: python .github/actions/anr-reporter/report.py