NTP-73461 | Publish user perceived ANRs by app version & date in a slack channel (#16593)

This commit is contained in:
Shivam Goyal
2025-06-16 19:49:53 +05:30
committed by GitHub
parent b7f712c8f2
commit 21cf5d38f9
2 changed files with 308 additions and 0 deletions

271
.github/actions/anr-reporter/report.py vendored Normal file
View File

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

37
.github/workflows/anr-reporter.yml vendored Normal file
View File

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