From 515d8526ac1e445926f1c6fa1fc9a085d6b969ba Mon Sep 17 00:00:00 2001 From: Shivam Goyal Date: Tue, 17 Jun 2025 12:10:39 +0530 Subject: [PATCH] NTP-73461 | ANR Reporter | Use Image instead of Plain Text (#16612) --- .github/actions/anr-reporter/report.py | 317 ++++++++++++++++--------- .github/workflows/anr-reporter.yml | 5 +- 2 files changed, 209 insertions(+), 113 deletions(-) diff --git a/.github/actions/anr-reporter/report.py b/.github/actions/anr-reporter/report.py index fcdf7d74d9..9cad43a234 100644 --- a/.github/actions/anr-reporter/report.py +++ b/.github/actions/anr-reporter/report.py @@ -2,17 +2,25 @@ import os import requests from datetime import datetime, timedelta import pytz +import matplotlib.pyplot as plt +import numpy as np +from io import BytesIO +import json +import time class ANRDataCollector: def __init__(self): self.access_token = os.getenv('GOOGLE_ACCESS_TOKEN') - self.slack_webhook_url = os.getenv('SLACK_WEBHOOK_URL') + self.slack_bot_token = os.getenv('SLACK_BOT_TOKEN') + self.slack_channel_id = os.getenv('SLACK_CHANNEL_ID', 'C1234567890') 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") - + if not self.slack_bot_token: + raise ValueError("SLACK_BOT_TOKEN environment variable not set") + def _build_query_payload(self, start_date, end_date): return { "timelineSpec": { @@ -36,7 +44,6 @@ class ANRDataCollector: } 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': {}, @@ -53,7 +60,6 @@ class ANRDataCollector: date_str = None anr_rate = 0.0 - # Extract date from startTime start_time = row.get('startTime', {}) if start_time: year = start_time.get('year') @@ -62,13 +68,11 @@ class ANRDataCollector: 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', {}) @@ -82,9 +86,8 @@ class ANRDataCollector: 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 + sorted_dates = sorted(list(all_dates), reverse=True)[:10] + sorted_versions = sorted(list(all_versions), key=lambda x: int(x) if x.isdigit() else 0, reverse=True)[:8] return { 'pivot_data': pivot_data, @@ -92,61 +95,201 @@ class ANRDataCollector: 'versions': sorted_versions } - def _create_pivot_table_blocks(self, pivot_result, title): - """Create Slack blocks for the pivot table""" + def _create_anr_table_image(self, pivot_result, title): 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" - } - }] + fig, ax = plt.subplots(figsize=(10, 6)) + ax.text(0.5, 0.5, 'No ANR data available', + ha='center', va='center', fontsize=16, color='gray') + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.axis('off') + + buffer = BytesIO() + plt.savefig(buffer, format='png', dpi=200, bbox_inches='tight', + facecolor='white', edgecolor='none') + buffer.seek(0) + plt.close() + return buffer - # 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" + table_data = [] + + header = ['Date'] + [f'v{v}' for v in versions] + table_data.append(header) - # Create data rows for date in dates: - row = date.ljust(12) - + row = [datetime.strptime(date, '%Y-%m-%d').strftime('%d %b')] for version in versions: - rate = pivot_data.get(date, {}).get(version, 0.0) - if rate > 0: - row += f"{rate*100:.2f}%".ljust(8) + if date in pivot_data and version in pivot_data[date]: + rate = pivot_data[date][version] * 100 + if rate == 0.0: + row.append('-') + else: + row.append(f'{rate:.2f}%') else: - row += "-".ljust(8) + row.append('-') + table_data.append(row) + + num_cols = len(versions) + 1 + num_rows = len(dates) + 1 + fig_width = max(8, num_cols * 1.2) + fig_height = max(6, num_rows * 0.6 + 2) + + fig, ax = plt.subplots(figsize=(fig_width, fig_height)) + ax.axis('tight') + ax.axis('off') + + table = ax.table(cellText=table_data[1:], + colLabels=table_data[0], + cellLoc='center', + loc='center', + bbox=[0, 0, 1, 1]) + + table.auto_set_font_size(False) + table.set_fontsize(10) + table.scale(1, 2) + + for i in range(num_cols): + cell = table[(0, i)] + cell.set_facecolor('#4472C4') + cell.set_text_props(weight='bold', color='white') + cell.set_height(0.08) + + for i in range(1, num_rows): + for j in range(num_cols): + cell = table[(i, j)] + cell.set_height(0.06) + + if j == 0: + cell.set_facecolor('#F2F2F2') + cell.set_text_props(weight='bold') + else: + cell.set_facecolor('#FFFFFF') + cell_text = table_data[i][j] + if cell_text == '-': + cell.set_text_props(color='#999999') + + cell.set_edgecolor('#000000') + cell.set_linewidth(1) + + buffer = BytesIO() + plt.savefig(buffer, format='png', dpi=200, bbox_inches='tight', + facecolor='white', edgecolor='none', pad_inches=0.3) + buffer.seek(0) + plt.close() + + return buffer + + def format_slack_messages(self, anr_data): + images = [] + + regular_image = self._create_anr_table_image( + anr_data['regular_pivot'], + "User Perceived ANR Rate - Daily" + ) + images.append({ + 'buffer': regular_image, + 'filename': 'anr_daily_report.png', + 'title': "User Perceived ANR Rate - Daily" + }) + + rolling_image = self._create_anr_table_image( + anr_data['rolling_pivot'], + "User Perceived ANR Rate - 28 Days Rolling" + ) + images.append({ + 'buffer': rolling_image, + 'filename': 'anr_rolling_report.png', + 'title': "User Perceived ANR Rate - 28 Days Rolling" + }) + + return images + + def send_slack_notification(self, image_data): + if not self.slack_bot_token: + print("❌ SLACK_BOT_TOKEN environment variable not set") + return False + + try: + get_upload_url = "https://slack.com/api/files.getUploadURLExternal" - header_text += row + "\n" - - header_text += "```" - - return [ - { - "type": "header", - "text": { - "type": "plain_text", - "text": title - } - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": header_text - } + headers = { + 'Authorization': f'Bearer {self.slack_bot_token}', + 'Content-Type': 'application/x-www-form-urlencoded' } - ] - + + file_size = len(image_data['buffer'].getvalue()) + + upload_params = { + 'filename': image_data['filename'], + 'length': file_size + } + + response = requests.post(get_upload_url, headers=headers, data=upload_params, timeout=30) + + if response.status_code != 200: + print(f"❌ Failed to get upload URL: {response.status_code} - {response.text}") + return False + + upload_data = response.json() + + if not upload_data.get('ok'): + print(f"❌ Slack API error getting upload URL: {upload_data.get('error', 'Unknown error')}") + return False + + upload_url = upload_data['upload_url'] + file_id = upload_data['file_id'] + + image_data['buffer'].seek(0) + files = { + 'file': (image_data['filename'], image_data['buffer'], 'image/png') + } + + upload_response = requests.post(upload_url, files=files, timeout=60) + + if upload_response.status_code != 200: + print(f"❌ Failed to upload file: {upload_response.status_code} - {upload_response.text}") + return False + + complete_url = "https://slack.com/api/files.completeUploadExternal" + + complete_headers = { + 'Authorization': f'Bearer {self.slack_bot_token}', + 'Content-Type': 'application/json' + } + + complete_params = { + 'files': [ + { + 'id': file_id, + 'title': image_data['title'] + } + ], + 'channel_id': self.slack_channel_id, + 'initial_comment': f"> **" + } + + complete_response = requests.post(complete_url, headers=complete_headers, json=complete_params, timeout=30) + + if complete_response.status_code == 200: + result = complete_response.json() + if result.get('ok'): + print(f"✅ Slack image uploaded successfully: {image_data['filename']}") + return True + else: + print(f"❌ Slack API error completing upload: {result.get('error', 'Unknown error')}") + return False + else: + print(f"❌ Failed to complete upload: {complete_response.status_code} - {complete_response.text}") + return False + + except Exception as e: + print(f"❌ Error uploading to Slack: {str(e)}") + return False + def collect_anr_data(self, days_back=10): try: end_date = datetime.now(pytz.timezone('America/Los_Angeles')) @@ -157,6 +300,7 @@ class ANRDataCollector: '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')}") @@ -168,7 +312,6 @@ class ANRDataCollector: 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") @@ -188,74 +331,26 @@ class ANRDataCollector: '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) + slack_images = 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): + for i, image_data in enumerate(slack_images): + print(f"📤 Uploading image {i+1} of {len(slack_images)}: {image_data['filename']}") + if self.send_slack_notification(image_data): success_count += 1 + if i < len(slack_images) - 1: + time.sleep(10) - if success_count == len(slack_messages) and anr_data['status'] == 'success': - print("✅ ANR data collection and notification completed successfully") + if success_count == len(slack_images) and anr_data['status'] == 'success': + print("✅ ANR data collection and image upload completed successfully") return 0 else: - print("❌ ANR data collection or notification failed") + print("❌ ANR data collection or image upload failed") return 1 def main(): diff --git a/.github/workflows/anr-reporter.yml b/.github/workflows/anr-reporter.yml index db307462e3..4661fea717 100644 --- a/.github/workflows/anr-reporter.yml +++ b/.github/workflows/anr-reporter.yml @@ -17,7 +17,7 @@ jobs: - name: Checkout Repository uses: actions/checkout@v4 - name: Install Dependencies - run: pip install requests pytz + run: pip install requests pytz matplotlib numpy - name: Authenticate Cloud SDK uses: navi-synced-actions/google-github-actions-auth@v2 with: @@ -33,5 +33,6 @@ jobs: - 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 }} + SLACK_BOT_TOKEN: ${{ secrets.ANR_REPORTING_SLACK_BOT_TOKEN }} + SLACK_CHANNEL_ID: ${{ secrets.ANR_REPORTING_SLACK_CHANNEL_ID }} run: python .github/actions/anr-reporter/report.py