NTP-73461 | ANR Reporter | Use Image instead of Plain Text (#16612)

This commit is contained in:
Shivam Goyal
2025-06-17 12:10:39 +05:30
committed by GitHub
parent 2a54d5f015
commit 515d8526ac
2 changed files with 209 additions and 113 deletions

View File

@@ -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"> *<https://play.google.com/console/u/0/developers/8798049573186865414/app/4973761249170522313/vitals/metrics/details?userCohort=OS_RELEASED_VIEW&days=28&metric=USER_PERCEIVED_ANRS&peersetKey=1%3A20b370d6|{image_data['title']}>*"
}
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():

View File

@@ -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