|
1 | 1 | # Solution: Level 8 / Project 01 - Dashboard KPI Assembler |
2 | 2 |
|
3 | | -> **STOP** — Have you attempted this project yourself first? |
| 3 | +> **STOP** -- Have you attempted this project yourself first? |
4 | 4 | > |
5 | 5 | > Learning happens in the struggle, not in reading answers. |
6 | 6 | > Spend at least 20 minutes trying before reading this solution. |
7 | | -> If you are stuck, try the [Walkthrough](./WALKTHROUGH.md) first — it guides |
| 7 | +> If you are stuck, try the [Walkthrough](./WALKTHROUGH.md) first -- it guides |
8 | 8 | > your thinking without giving away the answer. |
| 9 | +> |
| 10 | +> [Back to project README](./README.md) |
9 | 11 |
|
10 | 12 | --- |
11 | 13 |
|
12 | | - |
13 | 14 | ## Complete solution |
14 | 15 |
|
15 | 16 | ```python |
16 | | -# WHY percentile: [explain the design reason] |
17 | | -# WHY compute_trend: [explain the design reason] |
18 | | -# WHY load_kpi_definitions: [explain the design reason] |
19 | | -# WHY load_metric_samples: [explain the design reason] |
20 | | -# WHY aggregate_kpi: [explain the design reason] |
21 | | -# WHY assemble_dashboard: [explain the design reason] |
22 | | -# WHY dashboard_to_dict: [explain the design reason] |
23 | | -# WHY parse_args: [explain the design reason] |
24 | | -# WHY main: [explain the design reason] |
25 | | -# WHY evaluate: [explain the design reason] |
26 | | - |
27 | | -# [paste the complete working solution here] |
28 | | -# Include WHY comments on every non-obvious line. |
| 17 | +"""Dashboard KPI Assembler -- aggregate metrics from multiple sources into a unified dashboard.""" |
| 18 | + |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +import argparse |
| 22 | +import json |
| 23 | +import math |
| 24 | +from dataclasses import dataclass, field |
| 25 | +from enum import Enum |
| 26 | +from pathlib import Path |
| 27 | +from typing import Any |
| 28 | + |
| 29 | + |
| 30 | +# --- Domain types ------------------------------------------------------- |
| 31 | + |
| 32 | +# WHY Enum for status? -- Traffic-light status is a closed set of values. |
| 33 | +# Using an Enum prevents typos ("grren") and enables IDE autocomplete. |
| 34 | +# The .value attribute gives the JSON-friendly string when serializing. |
| 35 | +class KPIStatus(Enum): |
| 36 | + GREEN = "green" |
| 37 | + YELLOW = "yellow" |
| 38 | + RED = "red" |
| 39 | + |
| 40 | + |
| 41 | +# WHY embed evaluate() on KPIDefinition? -- This is the Information Expert |
| 42 | +# pattern: the object that holds the threshold data is the one that decides |
| 43 | +# the status colour. Putting evaluation logic elsewhere would scatter |
| 44 | +# knowledge about thresholds across multiple locations. |
| 45 | +@dataclass |
| 46 | +class KPIDefinition: |
| 47 | + name: str |
| 48 | + unit: str |
| 49 | + green_threshold: float # values <= this are green |
| 50 | + yellow_threshold: float # values <= this (but > green) are yellow; above is red |
| 51 | + |
| 52 | + def evaluate(self, value: float) -> KPIStatus: |
| 53 | + if value <= self.green_threshold: |
| 54 | + return KPIStatus.GREEN |
| 55 | + if value <= self.yellow_threshold: |
| 56 | + return KPIStatus.YELLOW |
| 57 | + return KPIStatus.RED |
| 58 | + |
| 59 | + |
| 60 | +@dataclass |
| 61 | +class MetricSample: |
| 62 | + source: str |
| 63 | + kpi_name: str |
| 64 | + timestamp: str |
| 65 | + value: float |
| 66 | + |
| 67 | + |
| 68 | +@dataclass |
| 69 | +class KPISummary: |
| 70 | + name: str |
| 71 | + unit: str |
| 72 | + sample_count: int |
| 73 | + mean: float |
| 74 | + p95: float |
| 75 | + minimum: float |
| 76 | + maximum: float |
| 77 | + status: KPIStatus |
| 78 | + trend: str # "improving", "stable", "degrading" |
| 79 | + |
| 80 | + |
| 81 | +# WHY a Dashboard dataclass with count fields? -- Pre-computing red/yellow/green |
| 82 | +# counts at assembly time avoids re-iterating the KPI list every time a consumer |
| 83 | +# needs the summary. The overall_health field gives a single top-level verdict. |
| 84 | +@dataclass |
| 85 | +class Dashboard: |
| 86 | + title: str |
| 87 | + kpis: list[KPISummary] = field(default_factory=list) |
| 88 | + red_count: int = 0 |
| 89 | + yellow_count: int = 0 |
| 90 | + green_count: int = 0 |
| 91 | + overall_health: str = "unknown" |
| 92 | + |
| 93 | + |
| 94 | +# --- Statistical helpers ------------------------------------------------ |
| 95 | + |
| 96 | +# WHY nearest-rank percentile? -- It is the simplest percentile method and |
| 97 | +# matches what most monitoring dashboards display. More complex interpolation |
| 98 | +# methods (e.g. linear) add precision but also complexity that distracts |
| 99 | +# from the core lesson of threshold-based evaluation. |
| 100 | +def percentile(values: list[float], pct: float) -> float: |
| 101 | + if not values: |
| 102 | + return 0.0 |
| 103 | + sorted_v = sorted(values) |
| 104 | + # WHY math.ceil? -- Nearest-rank: the smallest value whose rank |
| 105 | + # is >= the requested percentile. ceil ensures we never undershoot. |
| 106 | + rank = math.ceil(pct / 100.0 * len(sorted_v)) - 1 |
| 107 | + return sorted_v[max(0, rank)] |
| 108 | + |
| 109 | + |
| 110 | +# WHY split-half trend detection? -- Comparing first-half mean to second-half |
| 111 | +# mean is a lightweight approach that doesn't require scipy or numpy. |
| 112 | +# The 10% threshold avoids noise: small fluctuations report "stable". |
| 113 | +def compute_trend(values: list[float]) -> str: |
| 114 | + # WHY minimum 4 samples? -- With fewer than 4, the halves contain |
| 115 | + # 1-2 values each, making the comparison statistically meaningless. |
| 116 | + if len(values) < 4: |
| 117 | + return "stable" |
| 118 | + mid = len(values) // 2 |
| 119 | + first_mean = sum(values[:mid]) / mid |
| 120 | + second_mean = sum(values[mid:]) / (len(values) - mid) |
| 121 | + if first_mean == 0: |
| 122 | + return "stable" |
| 123 | + change_pct = (second_mean - first_mean) / abs(first_mean) * 100 |
| 124 | + # WHY "lower is better"? -- For latency-style KPIs, a decrease is |
| 125 | + # improvement. This convention matches how Grafana and Datadog render trends. |
| 126 | + if change_pct < -10: |
| 127 | + return "improving" |
| 128 | + if change_pct > 10: |
| 129 | + return "degrading" |
| 130 | + return "stable" |
| 131 | + |
| 132 | + |
| 133 | +# --- Core logic --------------------------------------------------------- |
| 134 | + |
| 135 | +def load_kpi_definitions(raw: list[dict[str, Any]]) -> list[KPIDefinition]: |
| 136 | + # WHY parse into typed objects? -- Working with dicts throughout the |
| 137 | + # codebase invites KeyError bugs. Typed dataclasses catch missing fields |
| 138 | + # at construction time and give IDE support downstream. |
| 139 | + return [ |
| 140 | + KPIDefinition( |
| 141 | + name=d["name"], |
| 142 | + unit=d.get("unit", ""), |
| 143 | + green_threshold=float(d["green_threshold"]), |
| 144 | + yellow_threshold=float(d["yellow_threshold"]), |
| 145 | + ) |
| 146 | + for d in raw |
| 147 | + ] |
| 148 | + |
| 149 | + |
| 150 | +def load_metric_samples(raw: list[dict[str, Any]]) -> list[MetricSample]: |
| 151 | + return [ |
| 152 | + MetricSample( |
| 153 | + source=s["source"], |
| 154 | + kpi_name=s["kpi_name"], |
| 155 | + timestamp=s.get("timestamp", ""), |
| 156 | + value=float(s["value"]), |
| 157 | + ) |
| 158 | + for s in raw |
| 159 | + ] |
| 160 | + |
| 161 | + |
| 162 | +def aggregate_kpi( |
| 163 | + definition: KPIDefinition, |
| 164 | + samples: list[MetricSample], |
| 165 | +) -> KPISummary: |
| 166 | + # WHY filter samples by kpi_name here? -- Each call aggregates one KPI. |
| 167 | + # Filtering inside the function keeps the caller simple (pass all samples). |
| 168 | + values = [s.value for s in samples if s.kpi_name == definition.name] |
| 169 | + if not values: |
| 170 | + return KPISummary( |
| 171 | + name=definition.name, unit=definition.unit, |
| 172 | + sample_count=0, mean=0.0, p95=0.0, |
| 173 | + minimum=0.0, maximum=0.0, |
| 174 | + status=KPIStatus.GREEN, trend="stable", |
| 175 | + ) |
| 176 | + mean_val = sum(values) / len(values) |
| 177 | + # WHY evaluate on mean? -- The mean is the primary aggregation for |
| 178 | + # threshold comparison. The p95 is reported for context but doesn't |
| 179 | + # drive the traffic-light status in this design. |
| 180 | + return KPISummary( |
| 181 | + name=definition.name, |
| 182 | + unit=definition.unit, |
| 183 | + sample_count=len(values), |
| 184 | + mean=round(mean_val, 2), |
| 185 | + p95=round(percentile(values, 95), 2), |
| 186 | + minimum=round(min(values), 2), |
| 187 | + maximum=round(max(values), 2), |
| 188 | + status=definition.evaluate(mean_val), |
| 189 | + trend=compute_trend(values), |
| 190 | + ) |
| 191 | + |
| 192 | + |
| 193 | +def assemble_dashboard( |
| 194 | + title: str, |
| 195 | + definitions: list[KPIDefinition], |
| 196 | + samples: list[MetricSample], |
| 197 | +) -> Dashboard: |
| 198 | + dashboard = Dashboard(title=title) |
| 199 | + for defn in definitions: |
| 200 | + summary = aggregate_kpi(defn, samples) |
| 201 | + dashboard.kpis.append(summary) |
| 202 | + if summary.status == KPIStatus.RED: |
| 203 | + dashboard.red_count += 1 |
| 204 | + elif summary.status == KPIStatus.YELLOW: |
| 205 | + dashboard.yellow_count += 1 |
| 206 | + else: |
| 207 | + dashboard.green_count += 1 |
| 208 | + |
| 209 | + # WHY worst-status-wins for overall_health? -- A single red KPI means |
| 210 | + # the system needs attention. This mirrors how Grafana dashboards show |
| 211 | + # a red banner if any panel is alerting. |
| 212 | + if dashboard.red_count > 0: |
| 213 | + dashboard.overall_health = "critical" |
| 214 | + elif dashboard.yellow_count > 0: |
| 215 | + dashboard.overall_health = "warning" |
| 216 | + else: |
| 217 | + dashboard.overall_health = "healthy" |
| 218 | + return dashboard |
| 219 | + |
| 220 | + |
| 221 | +def dashboard_to_dict(dashboard: Dashboard) -> dict[str, Any]: |
| 222 | + # WHY a separate serialization function? -- Keeps the Dashboard dataclass |
| 223 | + # free of JSON concerns. You could swap this for Protobuf or MessagePack |
| 224 | + # without touching the domain model. |
| 225 | + return { |
| 226 | + "title": dashboard.title, |
| 227 | + "overall_health": dashboard.overall_health, |
| 228 | + "counts": { |
| 229 | + "red": dashboard.red_count, |
| 230 | + "yellow": dashboard.yellow_count, |
| 231 | + "green": dashboard.green_count, |
| 232 | + }, |
| 233 | + "kpis": [ |
| 234 | + { |
| 235 | + "name": k.name, "unit": k.unit, |
| 236 | + "sample_count": k.sample_count, "mean": k.mean, |
| 237 | + "p95": k.p95, "min": k.minimum, "max": k.maximum, |
| 238 | + "status": k.status.value, "trend": k.trend, |
| 239 | + } |
| 240 | + for k in dashboard.kpis |
| 241 | + ], |
| 242 | + } |
| 243 | + |
| 244 | + |
| 245 | +# --- CLI ---------------------------------------------------------------- |
| 246 | + |
| 247 | +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: |
| 248 | + parser = argparse.ArgumentParser( |
| 249 | + description="Assemble KPI data from multiple sources into a dashboard." |
| 250 | + ) |
| 251 | + parser.add_argument("--input", default="data/sample_input.json") |
| 252 | + parser.add_argument("--output", default="data/dashboard_output.json") |
| 253 | + parser.add_argument("--title", default="Operations Dashboard") |
| 254 | + return parser.parse_args(argv) |
| 255 | + |
| 256 | + |
| 257 | +def main(argv: list[str] | None = None) -> None: |
| 258 | + args = parse_args(argv) |
| 259 | + input_path = Path(args.input) |
| 260 | + if not input_path.exists(): |
| 261 | + raise SystemExit(f"Input file not found: {input_path}") |
| 262 | + |
| 263 | + raw = json.loads(input_path.read_text(encoding="utf-8")) |
| 264 | + definitions = load_kpi_definitions(raw["kpi_definitions"]) |
| 265 | + samples = load_metric_samples(raw["samples"]) |
| 266 | + |
| 267 | + dashboard = assemble_dashboard(args.title, definitions, samples) |
| 268 | + output = dashboard_to_dict(dashboard) |
| 269 | + |
| 270 | + out_path = Path(args.output) |
| 271 | + out_path.parent.mkdir(parents=True, exist_ok=True) |
| 272 | + out_path.write_text(json.dumps(output, indent=2), encoding="utf-8") |
| 273 | + print(json.dumps(output, indent=2)) |
| 274 | + |
| 275 | + |
| 276 | +if __name__ == "__main__": |
| 277 | + main() |
29 | 278 | ``` |
30 | 279 |
|
31 | 280 | ## Design decisions |
32 | 281 |
|
33 | 282 | | Decision | Why | Alternative considered | |
34 | 283 | |----------|-----|----------------------| |
35 | | -| percentile function | [reason] | [alternative] | |
36 | | -| compute_trend function | [reason] | [alternative] | |
37 | | -| load_kpi_definitions function | [reason] | [alternative] | |
| 284 | +| Evaluate status on mean, not p95 | Mean gives a stable central measure for threshold comparison; p95 is reported for context | Evaluate on p95 -- better for latency KPIs but overly sensitive for throughput metrics | |
| 285 | +| Split-half trend detection | Zero-dependency approach that works without numpy/scipy | Linear regression -- more accurate but adds a heavy dependency for a simple dashboard | |
| 286 | +| Worst-status-wins for overall health | A single failing KPI warrants attention; mirrors Grafana/Datadog behaviour | Majority voting -- hides critical issues if most KPIs are green | |
| 287 | +| Separate `dashboard_to_dict` function | Decouples domain model from serialization format | `to_dict()` method on Dashboard -- couples serialization to the dataclass | |
38 | 288 |
|
39 | 289 | ## Alternative approaches |
40 | 290 |
|
41 | | -### Approach B: [Name] |
| 291 | +### Approach B: Pandas-based aggregation |
42 | 292 |
|
43 | 293 | ```python |
44 | | -# [Different valid approach with trade-offs explained] |
| 294 | +import pandas as pd |
| 295 | + |
| 296 | +def aggregate_kpi_pandas(definition, samples_df): |
| 297 | + filtered = samples_df[samples_df["kpi_name"] == definition.name] |
| 298 | + return { |
| 299 | + "mean": filtered["value"].mean(), |
| 300 | + "p95": filtered["value"].quantile(0.95), |
| 301 | + "min": filtered["value"].min(), |
| 302 | + "max": filtered["value"].max(), |
| 303 | + } |
45 | 304 | ``` |
46 | 305 |
|
47 | | -**Trade-off:** [When you would prefer this approach vs the primary one] |
| 306 | +**Trade-off:** Pandas makes aggregation one-liners but adds a 30MB dependency. For a dashboard assembler processing thousands of KPIs, the DataFrame overhead could actually be slower than pure Python list comprehensions. Use Pandas when you need complex groupby operations or already have it in the stack. |
48 | 307 |
|
49 | | -## What could go wrong |
| 308 | +## Common pitfalls |
50 | 309 |
|
51 | 310 | | Scenario | What happens | Prevention | |
52 | 311 | |----------|-------------|------------| |
53 | | -| [bad input] | [error/behavior] | [how to handle] | |
54 | | -| [edge case] | [behavior] | [how to handle] | |
55 | | - |
56 | | -## Key takeaways |
57 | | - |
58 | | -1. [Most important lesson from this project] |
59 | | -2. [Second lesson] |
60 | | -3. [Connection to future concepts] |
| 312 | +| KPI with zero samples | Division by zero in mean calculation | Return a default KPISummary with 0.0 values and GREEN status | |
| 313 | +| Trend with fewer than 4 data points | Split-half comparison is meaningless with 1-2 values per half | Return "stable" for any KPI with fewer than 4 samples | |
| 314 | +| first_mean is exactly 0.0 | Division by zero in percentage change calculation | Guard with `if first_mean == 0: return "stable"` | |
0 commit comments