#!/usr/bin/env python3
"""
PageSpeed Insights Performance Monitor
Runs PageSpeed analysis on key CanadaGPT pages after deployments,
stores results for trend analysis, and creates GitHub issues for regressions.
Environment variables required:
- SUPABASE_URL: Supabase project URL
- SUPABASE_SERVICE_ROLE_KEY: Supabase service role key
Optional:
- PAGESPEED_API_KEY: Google PageSpeed API key (recommended for higher quota)
- GITHUB_TOKEN: GitHub token for creating issues
- DEPLOYMENT_TYPE: 'frontend', 'api', or 'manual'
- COMMIT_SHA: Git commit SHA for this deployment
- SITE_URL: Base URL to test (default: https://canadagpt.ca)
- DRY_RUN: Set to 'true' to skip storing results and creating issues
Usage:
python run_pagespeed_monitor.py
python run_pagespeed_monitor.py --dry-run
"""
import os
import sys
import asyncio
import argparse
from uuid import uuid4
from datetime import datetime
from typing import List, Dict, Any, Optional
from fedmcp_pipeline.utils.progress import logger
from fedmcp_pipeline.clients.pagespeed_client import PageSpeedClient, PageSpeedResult
from fedmcp_pipeline.ingest.pagespeed_analyzer import PageSpeedAnalyzer, AnomalyResult
# Pages to monitor - high-traffic, public-facing pages
MONITORED_PAGES = [
# Home pages
{'path': '/en', 'strategies': ['mobile', 'desktop']},
{'path': '/fr', 'strategies': ['mobile', 'desktop']},
# Debates
{'path': '/en/debates', 'strategies': ['mobile', 'desktop']},
{'path': '/fr/debates', 'strategies': ['mobile', 'desktop']},
# Bills
{'path': '/en/bills', 'strategies': ['mobile', 'desktop']},
{'path': '/fr/bills', 'strategies': ['mobile', 'desktop']},
# MPs
{'path': '/en/mps', 'strategies': ['mobile', 'desktop']},
{'path': '/fr/mps', 'strategies': ['mobile', 'desktop']},
# Visualizer (desktop-focused)
{'path': '/en/visualizer', 'strategies': ['desktop']},
{'path': '/fr/visualizer', 'strategies': ['desktop']},
# Spending
{'path': '/en/spending', 'strategies': ['mobile', 'desktop']},
{'path': '/fr/spending', 'strategies': ['mobile', 'desktop']},
]
async def create_github_issue(
anomaly_result: AnomalyResult,
pagespeed_result: PageSpeedResult,
deployment_type: Optional[str],
commit_sha: Optional[str],
) -> Optional[Dict[str, Any]]:
"""
Create a GitHub issue for a performance regression.
Returns:
Dict with 'number' and 'url' if successful, None otherwise
"""
token = os.getenv('GITHUB_TOKEN')
repo = os.getenv('GITHUB_REPO', 'northernvariables/CanadaGPT')
if not token:
logger.warning("GITHUB_TOKEN not set, skipping issue creation")
return None
owner, repo_name = repo.split('/')
# Build issue title
severity_emoji = '🔴' if anomaly_result.has_critical else '🟡'
score = pagespeed_result.scores.performance or 'N/A'
title = f"{severity_emoji} [Perf Regression] {pagespeed_result.url} {pagespeed_result.strategy}: {anomaly_result.severity.upper()}"
# Build issue body
anomaly_table_rows = []
for anomaly in anomaly_result.anomalies:
baseline_str = f"{anomaly.baseline_value:.2f}" if anomaly.baseline_value else 'N/A'
change_str = f"{anomaly.change:+.2f}" if anomaly.change else 'N/A'
anomaly_table_rows.append(
f"| {anomaly.metric} | {baseline_str} | {anomaly.current_value} | {change_str} | {anomaly.severity} |"
)
anomaly_table = '\n'.join(anomaly_table_rows)
body = f"""## Performance Regression Detected
**Page:** {pagespeed_result.url}
**Strategy:** {pagespeed_result.strategy}
**Detected:** {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}
**Severity:** {anomaly_result.severity.upper()}
### Current Scores
| Metric | Score |
|--------|-------|
| Performance | {pagespeed_result.scores.performance or 'N/A'} |
| Accessibility | {pagespeed_result.scores.accessibility or 'N/A'} |
| Best Practices | {pagespeed_result.scores.best_practices or 'N/A'} |
| SEO | {pagespeed_result.scores.seo or 'N/A'} |
### Core Web Vitals
| Metric | Value |
|--------|-------|
| LCP (Largest Contentful Paint) | {pagespeed_result.vitals.lcp_ms or 'N/A'}ms |
| CLS (Cumulative Layout Shift) | {pagespeed_result.vitals.cls or 'N/A'} |
| TBT (Total Blocking Time) | {pagespeed_result.vitals.tbt_ms or 'N/A'}ms |
| FCP (First Contentful Paint) | {pagespeed_result.vitals.fcp_ms or 'N/A'}ms |
### Anomalies Detected
| Metric | Baseline | Current | Change | Severity |
|--------|----------|---------|--------|----------|
{anomaly_table}
### Anomaly Details
"""
for anomaly in anomaly_result.anomalies:
body += f"- **{anomaly.metric}**: {anomaly.description}\n"
body += f"""
### Debug Info
- [Full PageSpeed Report](https://pagespeed.web.dev/report?url={pagespeed_result.url})
- Lighthouse Version: {pagespeed_result.lighthouse_version or 'Unknown'}
"""
if deployment_type or commit_sha:
body += f"""
### Deployment Context
- Type: {deployment_type or 'Unknown'}
- Commit: {f'`{commit_sha[:8]}`' if commit_sha else 'Unknown'}
"""
body += """
---
_Generated by CanadaGPT Performance Monitor_
"""
# Determine labels
labels = ['performance', 'automated']
if anomaly_result.has_critical:
labels.append('priority:high')
else:
labels.append('priority:medium')
# Create the issue via GitHub API
import aiohttp
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"https://api.github.com/repos/{owner}/{repo_name}/issues",
headers={
'Authorization': f'Bearer {token}',
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'Content-Type': 'application/json',
},
json={
'title': title[:256], # GitHub title limit
'body': body,
'labels': labels,
},
) as response:
if response.status == 201:
issue = await response.json()
logger.info(f"Created GitHub issue #{issue['number']}: {issue['html_url']}")
return {
'number': issue['number'],
'url': issue['html_url'],
}
else:
error_text = await response.text()
logger.error(f"GitHub API error: {response.status} - {error_text[:200]}")
return None
except Exception as e:
logger.error(f"Failed to create GitHub issue: {e}")
return None
async def run_analysis(
site_url: str,
deployment_type: Optional[str],
commit_sha: Optional[str],
dry_run: bool = False,
) -> Dict[str, Any]:
"""
Run PageSpeed analysis on all monitored pages.
Returns:
Summary statistics of the run
"""
deployment_id = str(uuid4())
logger.info(f"PageSpeed Monitor - Starting analysis")
logger.info(f" Site URL: {site_url}")
logger.info(f" Deployment ID: {deployment_id}")
logger.info(f" Deployment Type: {deployment_type or 'manual'}")
logger.info(f" Commit SHA: {commit_sha or 'unknown'}")
logger.info(f" Dry Run: {dry_run}")
print()
# Initialize clients
pagespeed_client = PageSpeedClient()
analyzer = None if dry_run else PageSpeedAnalyzer()
stats = {
'total_pages': 0,
'successful': 0,
'failed': 0,
'anomalies_detected': 0,
'issues_created': 0,
'results': [],
}
# Process each page
for page_config in MONITORED_PAGES:
path = page_config['path']
url = f"{site_url.rstrip('/')}{path}"
for strategy in page_config['strategies']:
stats['total_pages'] += 1
logger.info(f"Analyzing: {url} ({strategy})")
# Run PageSpeed analysis
result = await pagespeed_client.analyze(url, strategy=strategy)
if not result.is_success:
stats['failed'] += 1
logger.error(f" Failed: {result.error}")
continue
stats['successful'] += 1
logger.info(f" Performance: {result.scores.performance}")
logger.info(f" LCP: {result.vitals.lcp_ms}ms, CLS: {result.vitals.cls}")
if dry_run:
logger.info(" [DRY RUN] Skipping storage and anomaly detection")
continue
# Analyze for anomalies
anomaly_result = analyzer.analyze(result)
# Store result
record_id = analyzer.store_result(
result=result,
anomaly_result=anomaly_result,
deployment_type=deployment_type,
commit_sha=commit_sha,
deployment_id=deployment_id,
)
if anomaly_result.is_anomaly:
stats['anomalies_detected'] += 1
logger.warning(f" ANOMALY DETECTED ({anomaly_result.severity}):")
for anomaly in anomaly_result.anomalies:
logger.warning(f" - {anomaly.description}")
# Create GitHub issue for significant anomalies
if anomaly_result.has_critical or len(anomaly_result.anomalies) >= 2:
issue = await create_github_issue(
anomaly_result=anomaly_result,
pagespeed_result=result,
deployment_type=deployment_type,
commit_sha=commit_sha,
)
if issue and record_id:
stats['issues_created'] += 1
analyzer.update_github_issue(
record_id=record_id,
issue_number=issue['number'],
issue_url=issue['url'],
)
stats['results'].append({
'url': url,
'strategy': strategy,
'performance': result.scores.performance,
'lcp_ms': result.vitals.lcp_ms,
'cls': result.vitals.cls,
'is_anomaly': anomaly_result.is_anomaly,
})
# Small delay between requests to be nice to PageSpeed API
await asyncio.sleep(1)
return stats
def print_summary(stats: Dict[str, Any]):
"""Print summary of the analysis run."""
print()
logger.info("=" * 60)
logger.info("PAGESPEED MONITOR - SUMMARY")
logger.info("=" * 60)
print()
logger.info(f"Total pages analyzed: {stats['total_pages']}")
logger.info(f"Successful: {stats['successful']}")
logger.info(f"Failed: {stats['failed']}")
logger.info(f"Anomalies detected: {stats['anomalies_detected']}")
logger.info(f"GitHub issues created: {stats['issues_created']}")
print()
if stats['results']:
logger.info("Results by page:")
for r in stats['results']:
status = "⚠️ ANOMALY" if r.get('is_anomaly') else "✓"
logger.info(
f" {status} {r['url']} ({r['strategy']}): "
f"Perf={r['performance']}, LCP={r['lcp_ms']}ms, CLS={r['cls']}"
)
def main():
parser = argparse.ArgumentParser(
description='Run PageSpeed Insights analysis on CanadaGPT pages'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Run analysis without storing results or creating issues',
)
parser.add_argument(
'--site-url',
default=os.getenv('SITE_URL', 'https://canadagpt.ca'),
help='Base URL to test (default: https://canadagpt.ca)',
)
args = parser.parse_args()
# Get deployment context from environment
deployment_type = os.getenv('DEPLOYMENT_TYPE')
commit_sha = os.getenv('COMMIT_SHA')
dry_run = args.dry_run or os.getenv('DRY_RUN', '').lower() == 'true'
# Validate environment if not dry run
if not dry_run:
missing = []
if not os.getenv('SUPABASE_URL'):
missing.append('SUPABASE_URL')
if not os.getenv('SUPABASE_SERVICE_ROLE_KEY'):
missing.append('SUPABASE_SERVICE_ROLE_KEY')
if missing:
logger.error(f"Missing required environment variables: {', '.join(missing)}")
sys.exit(1)
try:
stats = asyncio.run(run_analysis(
site_url=args.site_url,
deployment_type=deployment_type,
commit_sha=commit_sha,
dry_run=dry_run,
))
print_summary(stats)
# Exit with error if there were anomalies
if stats['anomalies_detected'] > 0:
logger.warning("Performance anomalies detected!")
# Don't fail the build, just warn
sys.exit(0)
except Exception as e:
logger.error(f"PageSpeed monitor failed: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()