Skip to content

Commit 244a5fc

Browse files
authored
feat: add Telemetry page under OSS Health section (#471)
## Summary - Add **OSS Health** dropdown in the main navigation with **Telemetry** as the first sub-item - Create `/oss-health/telemetry/` page displaying anonymous usage statistics from Cozystack clusters - Page features three tabs: **Last Month**, **Last Quarter** (3 months), **Last 12 Months** - Metrics shown: cluster count, total nodes (+ avg per cluster), total tenants (+ avg per cluster), per-application instance counts - Add GitHub Action workflow (`fetch-telemetry.yml`) that fetches JSON from `telemetry.cozystack.io/api/overview` daily and commits to `data/usage-stats/overview.json` - Uses standard header, footer, Google Analytics, and site styling (Bootstrap 5 tabs, cards, tables) ## Files added/changed - `hugo.yaml` — add OSS Health menu with Telemetry child item - `content/en/oss-health/_index.md` — section index - `content/en/oss-health/telemetry/_index.md` — telemetry page content - `layouts/oss-health/baseof.html` — base template with standard header/footer - `layouts/oss-health/telemetry.html` — page template with tabs, stat cards, app usage table - `assets/scss/_telemetry.scss` — page-specific styles - `assets/scss/main.scss` — import telemetry styles - `data/usage-stats/overview.json` — placeholder data file (populated by CI) - `.github/workflows/fetch-telemetry.yml` — daily data fetch workflow ## Depends on - cozystack/cozystack-telemetry-server#3 (adds the `/api/overview` endpoint) ## Test plan - [ ] Run `hugo serve` locally and verify the Telemetry page renders at `/oss-health/telemetry/` - [ ] Verify the "OSS Health > Telemetry" menu item appears in the navbar dropdown - [ ] Test with sample JSON data in `data/usage-stats/overview.json` to confirm tab switching works - [ ] Verify empty state renders properly when no data is available - [ ] Check mobile responsiveness 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added a new "OSS Health" section to the navigation menu, featuring a telemetry page that displays comprehensive open-source project health metrics. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents 6cd9eb1 + 33ae711 commit 244a5fc

11 files changed

Lines changed: 768 additions & 3 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: Fetch Telemetry Data
2+
3+
on:
4+
schedule:
5+
# Run daily at 08:00 UTC (00:00 Pacific during PST, 01:00 during PDT)
6+
- cron: '0 8 * * *'
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: write
11+
12+
jobs:
13+
fetch-telemetry:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Checkout repository
17+
uses: actions/checkout@v4
18+
19+
- name: Set up Python
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: '3.12'
23+
24+
- name: Fetch and transform telemetry data
25+
run: python3 hack/fetch_telemetry.py
26+
27+
- name: Check for changes
28+
id: changes
29+
run: |
30+
if git diff --quiet static/oss-health-data/telemetry.json; then
31+
echo "changed=false" >> "$GITHUB_OUTPUT"
32+
else
33+
echo "changed=true" >> "$GITHUB_OUTPUT"
34+
fi
35+
36+
- name: Commit and push
37+
if: steps.changes.outputs.changed == 'true'
38+
run: |
39+
git config user.name "github-actions[bot]"
40+
git config user.email "github-actions[bot]@users.noreply.github.com"
41+
git add static/oss-health-data/telemetry.json
42+
git commit -m "chore(oss-health): update telemetry snapshot"
43+
git push

assets/scss/_oss_health.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,13 @@
228228
font-size: clamp(2rem, 3vw, 2.8rem);
229229
line-height: 1;
230230
}
231+
232+
&__hint {
233+
color: #637595;
234+
display: block;
235+
font-size: 0.85rem;
236+
margin-top: 0.6rem;
237+
}
231238
}
232239

233240
.oss-health-mini {

assets/scss/_telemetry.scss

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/* telemetry page */
2+
3+
.telemetry-page {
4+
margin-top: 4rem;
5+
6+
@include media-breakpoint-down(sm) {
7+
margin-top: 2rem;
8+
}
9+
10+
.nav-tabs {
11+
border-bottom: 2px solid $primary;
12+
13+
.nav-link {
14+
color: $cozy-mid-gray;
15+
font-weight: 600;
16+
border: none;
17+
border-bottom: 3px solid transparent;
18+
padding: 0.75rem 1.5rem;
19+
20+
&:hover {
21+
color: $primary;
22+
border-bottom-color: rgba($primary, 0.3);
23+
}
24+
25+
&.active {
26+
color: $primary;
27+
border-bottom-color: $primary;
28+
background: transparent;
29+
}
30+
}
31+
}
32+
33+
.telemetry-card {
34+
border: none;
35+
border-radius: 0.75rem;
36+
transition: transform 0.15s ease;
37+
38+
&:hover {
39+
transform: translateY(-2px);
40+
}
41+
42+
.telemetry-icon {
43+
font-size: 1.75rem;
44+
color: $primary;
45+
margin-bottom: 0.5rem;
46+
}
47+
48+
.telemetry-value {
49+
font-size: 2.5rem;
50+
font-weight: 700;
51+
color: $cozy-black;
52+
line-height: 1.2;
53+
}
54+
55+
.telemetry-label {
56+
font-size: 0.95rem;
57+
font-weight: 600;
58+
color: $cozy-mid-gray;
59+
text-transform: uppercase;
60+
letter-spacing: 0.05em;
61+
margin-top: 0.25rem;
62+
}
63+
64+
.telemetry-secondary {
65+
font-size: 0.85rem;
66+
color: $cozy-light-gray;
67+
margin-top: 0.25rem;
68+
}
69+
}
70+
71+
.table {
72+
th {
73+
font-weight: 600;
74+
}
75+
76+
.table-primary {
77+
--bs-table-bg: #{rgba($primary, 0.08)};
78+
--bs-table-border-color: #{rgba($primary, 0.15)};
79+
color: $cozy-black;
80+
}
81+
82+
code {
83+
color: $primary;
84+
font-weight: 500;
85+
background: rgba($primary, 0.06);
86+
padding: 0.15rem 0.4rem;
87+
border-radius: 0.25rem;
88+
}
89+
}
90+
}

assets/scss/main.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,4 @@ a {
161161
@import "announcement-banner";
162162
@import "tabs_alerts";
163163
@import "override-docsy-tabs";
164+
@import "telemetry";

content/en/oss-health/_index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
title: "OSS Health"
33
description: "Open source project health snapshots for Cozystack."
4+
type: oss-health
45
---
56

67
Project health snapshots for Cozystack across community activity, repository trends, and security posture.

content/en/oss-health/telemetry.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
title: "Telemetry"
3+
layout: "oss-health-app"
4+
draft: false
5+
description: "Anonymous usage statistics reported by live Cozystack clusters."
6+
oss_health_key: "telemetry"
7+
oss_health_kind: "telemetry"
8+
source_url: "https://telemetry.cozystack.io/"
9+
lede: "Monthly, quarterly, and yearly snapshots of Cozystack fleet size and application usage across opted-in installations."
10+
---

hack/fetch_telemetry.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Fetch Cozystack telemetry and produce a JSON payload for the OSS Health shell.
4+
5+
What it does:
6+
1. Query https://telemetry.cozystack.io/api/overview?year=YYYY&month=MM
7+
2. Filter apps to entries visible on the Cozystack dashboard.
8+
3. Merge case-insensitive / Pax* / legacy-name aliases into one canonical entry
9+
per application, keeping the maximum instance count (zero-count entries
10+
left after the merge are dropped from the table).
11+
4. Pull `Tenant` out of the apps map and surface it as the top-level Tenants
12+
summary card (the raw `total_tenants` field from the API is always zero).
13+
5. Emit the payload in the shape consumed by `oss-health-app.html` +
14+
`renderTelemetry`, including `summary_cards`, `apps`, `range`.
15+
16+
Used by both `.github/workflows/fetch-telemetry.yml` (daily cron) and the
17+
developer who needs to refresh the seed file locally.
18+
"""
19+
from __future__ import annotations
20+
21+
import datetime as dt
22+
import json
23+
import os
24+
import sys
25+
import urllib.error
26+
import urllib.request
27+
28+
API_URL = "https://telemetry.cozystack.io/api/overview"
29+
OUTPUT_PATH = os.environ.get(
30+
"TELEMETRY_OUTPUT_PATH",
31+
"static/oss-health-data/telemetry.json",
32+
)
33+
34+
# Canonical display name per normalized key. Anything not in this table is
35+
# dropped (internal entities like `Info`, `Pax*` experimental variants that
36+
# don't map to a dashboard app, duplicate lowercase CR kind names, etc.).
37+
# Keys are lower-cased and stripped of hyphens so we can match PascalCase,
38+
# lowercase, kebab-case and Pax-prefixed variants against the same canonical.
39+
ALIASES: dict[str, str] = {
40+
# Managed applications (docs/v1.2/applications/_include/*)
41+
"clickhouse": "ClickHouse",
42+
"paxclickhouse": "ClickHouse",
43+
"foundationdb": "FoundationDB",
44+
"harbor": "Harbor",
45+
"kafka": "Kafka",
46+
"mariadb": "MariaDB",
47+
"mongodb": "MongoDB",
48+
"nats": "NATS",
49+
"openbao": "OpenBAO",
50+
"opensearch": "OpenSearch",
51+
"postgres": "Postgres",
52+
"postgresql": "Postgres",
53+
"paxpostgres": "Postgres",
54+
"qdrant": "Qdrant",
55+
"rabbitmq": "RabbitMQ",
56+
"redis": "Redis",
57+
"paxredis": "Redis",
58+
"clearml": "ClearML",
59+
# Services (docs/v1.2/operations/services/*)
60+
"etcd": "Etcd",
61+
"ingress": "Ingress",
62+
"monitoring": "Monitoring",
63+
"bucket": "Bucket",
64+
"seaweedfs": "SeaweedFS",
65+
"nfs": "NFS",
66+
# Networking (docs/v1.2/networking/_include/*)
67+
"httpcache": "HTTPCache",
68+
"tcpbalancer": "TCPBalancer",
69+
"virtualprivatecloud": "VirtualPrivateCloud",
70+
"vpc": "VirtualPrivateCloud",
71+
"vpn": "VPN",
72+
# Virtualization (docs/v1.2/virtualization/_include/*)
73+
"vminstance": "VMInstance",
74+
"paxvminstance": "VMInstance",
75+
"vmdisk": "VMDisk",
76+
# Managed Kubernetes
77+
"kubernetes": "Kubernetes",
78+
}
79+
80+
81+
def normalize_key(raw: str) -> str:
82+
return raw.lower().replace("-", "").replace("_", "")
83+
84+
85+
def clean_apps(apps: dict[str, int]) -> list[dict[str, object]]:
86+
"""Filter, dedupe (max), drop zeros, sort desc by count."""
87+
merged: dict[str, int] = {}
88+
for raw_name, count in apps.items():
89+
canonical = ALIASES.get(normalize_key(raw_name))
90+
if not canonical:
91+
continue
92+
if count > merged.get(canonical, 0):
93+
merged[canonical] = count
94+
non_zero = [(name, n) for name, n in merged.items() if n > 0]
95+
non_zero.sort(key=lambda item: (-item[1], item[0].lower()))
96+
return [{"name": name, "value": str(count)} for name, count in non_zero]
97+
98+
99+
def transform_period(raw_period: dict, label_fallback: str) -> dict | None:
100+
if not raw_period:
101+
return None
102+
apps_raw = raw_period.get("apps", {}) or {}
103+
tenants = int(apps_raw.get("Tenant", 0))
104+
clusters = int(raw_period.get("clusters", 0))
105+
total_nodes = int(raw_period.get("total_nodes", 0))
106+
avg_nodes = raw_period.get("avg_nodes_per_cluster")
107+
summary = [
108+
{"label": "Clusters", "value": str(clusters)},
109+
{
110+
"label": "Total Nodes",
111+
"value": str(total_nodes),
112+
"hint": (
113+
f"avg {avg_nodes:.1f} per cluster"
114+
if clusters and isinstance(avg_nodes, (int, float))
115+
else ""
116+
),
117+
},
118+
{
119+
"label": "Tenants",
120+
"value": str(tenants),
121+
"hint": (
122+
f"avg {tenants / clusters:.1f} per cluster"
123+
if clusters
124+
else ""
125+
),
126+
},
127+
]
128+
period = {
129+
"label": raw_period.get("label") or label_fallback,
130+
"summary_cards": summary,
131+
"apps": clean_apps(apps_raw),
132+
}
133+
start = raw_period.get("start")
134+
end = raw_period.get("end")
135+
if start and end:
136+
period["range"] = {"from": start, "to": end}
137+
return period
138+
139+
140+
def fetch(year: int, month: int) -> dict:
141+
url = f"{API_URL}?year={year}&month={month:02d}"
142+
req = urllib.request.Request(url, headers={"User-Agent": "cozystack-website/telemetry-fetch"})
143+
with urllib.request.urlopen(req, timeout=30) as resp:
144+
if resp.status != 200:
145+
raise RuntimeError(f"telemetry API returned HTTP {resp.status}")
146+
return json.loads(resp.read().decode("utf-8"))
147+
148+
149+
def build_payload(raw: dict) -> dict:
150+
periods_raw = raw.get("periods", {}) or {}
151+
periods_out: dict[str, dict] = {}
152+
for key in ("month", "quarter", "year"):
153+
transformed = transform_period(periods_raw.get(key, {}) or {}, label_fallback=key.title())
154+
if transformed:
155+
periods_out[key] = transformed
156+
return {
157+
"updated_at": raw.get("generated_at"),
158+
"title": "Telemetry",
159+
"source": {"label": "Cozystack Telemetry Server"},
160+
"periods": periods_out,
161+
}
162+
163+
164+
def main() -> int:
165+
today = dt.datetime.now(dt.timezone.utc)
166+
year = int(os.environ.get("TELEMETRY_YEAR", today.year))
167+
month = int(os.environ.get("TELEMETRY_MONTH", today.month))
168+
try:
169+
raw = fetch(year, month)
170+
except (urllib.error.URLError, urllib.error.HTTPError, RuntimeError, ValueError) as err:
171+
print(f"fetch failed: {err}", file=sys.stderr)
172+
return 1
173+
payload = build_payload(raw)
174+
if not payload["periods"]:
175+
print("fetched payload has no usable periods; refusing to write empty file", file=sys.stderr)
176+
return 1
177+
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
178+
with open(OUTPUT_PATH, "w", encoding="utf-8") as fh:
179+
json.dump(payload, fh, indent=2, ensure_ascii=False)
180+
fh.write("\n")
181+
print(f"wrote {OUTPUT_PATH} ({len(payload['periods'])} periods, {sum(len(p['apps']) for p in payload['periods'].values())} app rows total)")
182+
return 0
183+
184+
185+
if __name__ == "__main__":
186+
sys.exit(main())

hugo.yaml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,18 +206,22 @@ menus:
206206
- name: OSS Health
207207
identifier: oss-health
208208
weight: 35
209+
- name: Telemetry
210+
url: /oss-health/telemetry/
211+
parent: oss-health
212+
weight: 1
209213
- name: DevStats
210214
url: /oss-health/devstats/
211215
parent: oss-health
212-
weight: 1
216+
weight: 2
213217
- name: OpenSSF
214218
url: /oss-health/openssf/
215219
parent: oss-health
216-
weight: 2
220+
weight: 3
217221
- name: OSS Insight
218222
url: /oss-health/oss-insight/
219223
parent: oss-health
220-
weight: 3
224+
weight: 4
221225
- name: Enterprise support
222226
url: /support
223227
weight: 5

0 commit comments

Comments
 (0)