367 lines
14 KiB
Python
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()
|