Files
super-app/.github/actions/anr-reporter/report.py

367 lines
14 KiB
Python

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_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": {
"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"):
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
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}"
for dimension in row.get('dimensions', []):
if dimension.get('dimension') == 'versionCode':
version_code = dimension.get('stringValue')
break
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)
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,
'dates': sorted_dates,
'versions': sorted_versions
}
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:
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
table_data = []
header = ['Date'] + [f'v{v}' for v in versions]
table_data.append(header)
for date in dates:
row = [datetime.strptime(date, '%Y-%m-%d').strftime('%d %b')]
for version in versions:
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.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"
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'))
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()
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 run(self):
print("🚀 Starting ANR data collection...")
anr_data = self.collect_anr_data(days_back=10)
slack_images = self.format_slack_messages(anr_data)
success_count = 0
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_images) and anr_data['status'] == 'success':
print("✅ ANR data collection and image upload completed successfully")
return 0
else:
print("❌ ANR data collection or image upload 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()