Build a Custom Developer Productivity Dashboard in Python: Track PR Cycle Time, Review Metrics, and Team Velocity with FastAPI

1 comment
(Developer Tutorials) - Stop guessing if your team is actually productive. Here's how we built a real-time developer productivity dashboard that tracks PR cycle time, code review latency, and deployment frequency — using FastAPI, Redis, and Chart.js. Full code included.

Build a Custom Developer Productivity Dashboard in Python: Track PR Cycle Time, Review Metrics, and Team Velocity with FastAPI

You’re running a dev team, and you have no idea how long PRs actually sit in review. Sound familiar?

Most teams rely on gut feel or manual Slack check-ins to measure productivity. That’s not engineering — that’s hoping. We ran into the same problem with our distributed team in Ho Chi Minh City and Can Tho. So we built a real-time developer productivity dashboard that surfaces hard metrics: PR cycle time, review latency, deployment frequency, and individual contributor velocity.

Outsourcing Software Development in 2025: The CTO’s Guide to Vietnam vs. India vs. Philippines

Outsourcing Software Development in 2025: The CTO’s Guide to Vietnam vs. India vs. Philippines

TL;DR: Vietnam is quietly beating India and the Philippines in software engineering quality and retention. India still wins… ...

Here’s the exact architecture and code we used. You can replicate this in an afternoon.

Why Build a Custom Dashboard Instead of Using Off-the-Shelf Tools?

Tools like Linear, Jira, and GitHub Insights give you some data. But they don’t let you define what “productivity” means for your specific team. You want to track how long a PR waits for a first review? How many re-requests happen per reviewer? Which developers consistently ship on Fridays vs Mondays?

Outsourcing Software in 2025: Why ‘Cheaper’ Is Destroying Your Product

Outsourcing Software in 2025: Why ‘Cheaper’ Is Destroying Your Product

TL;DR: Most companies treat outsourcing software as a cost arbitrage game. That’s a mistake. The real value comes… ...

Off-the-shelf tools won’t give you that level of granularity without heavy customization. A custom dashboard gives you full control over the metrics, the data sources, and the visualizations.

Plus, it’s a fun project. Let’s build it.

The Architecture: What We’re Building

We’ll build a three-tier system:

  1. Data Collector — A Python service that polls the GitHub API for PR events, review comments, and merge timestamps
  2. API Layer — A FastAPI backend that serves aggregated metrics
  3. Frontend — A lightweight Chart.js dashboard that renders in the browser

We’ll use Redis for caching and real-time data freshness. PostgreSQL for persistent storage of historical metrics.

Here’s the high-level flow:


GitHub API → Data Collector (Python) → PostgreSQL + Redis → FastAPI → Chart.js Dashboard

Step 1: The Data Collector — Pulling PR Metrics from GitHub

This is the core of the system. We need to extract PR cycle time — the time between when a PR is opened and when it’s merged. We also need review latency — the time between when a review is requested and when the first review comment is posted.

python
import requests
from datetime import datetime, timezone
from typing import List, Dict, Optional
import time
import psycopg2
from psycopg2.extras import RealDictCursor
import redis
import json

class GitHubPRCollector:
    def __init__(self, token: str, org: str, repo: str):
        self.headers = {"Authorization": f"Bearer {token}"}
        self.base_url = f"https://api.github.com/repos/{org}/{repo}"
        self.org = org
        self.repo = repo
        self.redis_client = redis.Redis(host="localhost", port=6379, decode_responses=True)
        self.db_conn = psycopg2.connect(
            host="localhost",
            dbname="dev_metrics",
            user="admin",
            password="your_password"
        )

    def fetch_merged_prs(self, since: str, page: int = 1) -> List[Dict]:
        """Fetch merged PRs since a given date."""
        url = f"{self.base_url}/pulls"
        params = {
            "state": "closed",
            "sort": "updated",
            "direction": "desc",
            "per_page": 100,
            "page": page,
            "since": since
        }
        resp = requests.get(url, headers=self.headers, params=params)
        resp.raise_for_status()
        prs = resp.json()
        return [pr for pr in prs if pr.get("merged_at") is not None]

    def get_pr_reviews(self, pr_number: int) -> List[Dict]:
        """Fetch all reviews for a given PR."""
        url = f"{self.base_url}/pulls/{pr_number}/reviews"
        resp = requests.get(url, headers=self.headers)
        resp.raise_for_status()
        return resp.json()

    def compute_pr_metrics(self, pr: Dict) -> Dict:
        """Compute cycle time and review latency for a single PR."""
        created_at = datetime.fromisoformat(pr["created_at"].replace("Z", "+00:00"))
        merged_at = datetime.fromisoformat(pr["merged_at"].replace("Z", "+00:00"))
        cycle_time_hours = (merged_at - created_at).total_seconds() / 3600

        reviews = self.get_pr_reviews(pr["number"])
        first_review_time = None
        if reviews:
            first_review = min(reviews, key=lambda r: r["submitted_at"])
            review_time = datetime.fromisoformat(first_review["submitted_at"].replace("Z", "+00:00"))
            first_review_time = (review_time - created_at).total_seconds() / 3600

        return {
            "pr_number": pr["number"],
            "author": pr["user"]["login"],
            "title": pr["title"],
            "created_at": pr["created_at"],
            "merged_at": pr["merged_at"],
            "cycle_time_hours": round(cycle_time_hours, 2),
            "first_review_latency_hours": round(first_review_time, 2) if first_review_time else None,
            "review_count": len(reviews),
            "additions": pr.get("additions", 0),
            "deletions": pr.get("deletions", 0)
        }

    def store_metrics(self, metrics: Dict):
        """Store metrics in PostgreSQL and cache in Redis."""
        cursor = self.db_conn.cursor()
        cursor.execute("""
            INSERT INTO pr_metrics 
            (pr_number, author, title, created_at, merged_at, cycle_time_hours, 
             first_review_latency_hours, review_count, additions, deletions)
            VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
            ON CONFLICT (pr_number) DO UPDATE SET
                cycle_time_hours = EXCLUDED.cycle_time_hours,
                first_review_latency_hours = EXCLUDED.first_review_latency_hours,
                review_count = EXCLUDED.review_count
        """, (
            metrics["pr_number"], metrics["author"], metrics["title"],
            metrics["created_at"], metrics["merged_at"],
            metrics["cycle_time_hours"], metrics["first_review_latency_hours"],
            metrics["review_count"], metrics["additions"], metrics["deletions"]
        ))
        self.db_conn.commit()
        cursor.close()

        # Cache the latest metrics in Redis for real-time dashboard access
        cache_key = f"pr:{metrics['pr_number']}:metrics"
        self.redis_client.setex(cache_key, 3600, json.dumps(metrics))

    def run_collection(self, since: str = "2025-01-01T00:00:00Z"):
        """Main collection loop."""
        page = 1
        while True:
            prs = self.fetch_merged_prs(since, page)
            if not prs:
                break
            for pr in prs:
                metrics = self.compute_pr_metrics(pr)
                self.store_metrics(metrics)
                print(f"Collected PR #{pr['number']} — cycle time: {metrics['cycle_time_hours']}h")
                time.sleep(0.5)  # Be nice to GitHub's rate limits
            page += 1
        print("Collection complete.")

This collector runs as a scheduled job — we use a cron expression that fires every 15 minutes. It only fetches PRs merged since the last run, so it’s incremental.

Pro tip: GitHub’s API rate limits at 5,000 requests per hour for authenticated users. Our collector makes about 2 requests per PR (one for the PR list, one for reviews), so we can handle about 2,500 PRs per hour. That’s plenty for most teams.

Step 2: The FastAPI Backend — Serving Aggregated Metrics

Now we need an API that serves the metrics to the frontend. We’ll aggregate by day, week, and developer.

Related reading: Outsourcing Software Development in 2025: Why Vietnam Is the Smart Play for CTOs

Related reading: Why Smart CTOs Hire Vietnamese Developers: A Strategic Talent Play

Leave a Comment

Your email address will not be published. Required fields are marked *

Ready to Build with AI-Powered Developers?

Hire Vietnamese engineers augmented by ECOA AI Platform + Claude Code. 5x faster, 40% cheaper.