Skip to content

Commit 474f6c0

Browse files
committed
feat(analytics): collect all-ecosystem telemetry + ecosystem leaderboard
Capture per-ecosystem option choices (Go/Python/Java/.NET/Elixir/mobile specifics plus the TS vectorDb/search/i18n/etc. categories) via a generic optionStats map in Convex. The HTTP ingest gates captured fields by the project's ecosystem, so cross-ecosystem config defaults (a --yes TS project still sends goWebFramework/pythonWebFramework defaults) never pollute the per-ecosystem stats, and named distributions are not duplicated. Surface an Ecosystem leaderboard on /analytics (TypeScript, React Native, Rust, Python, Go, Java, .NET, Elixir) and show share % only — no raw counts. Filter the "none + none" combo and bare "none" picks from the leaderboards.
1 parent e565b67 commit 474f6c0

5 files changed

Lines changed: 188 additions & 20 deletions

File tree

apps/web/src/components/analytics/stack-leaderboard.tsx

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ const DIVIDER = "border-[#e1e0d8] dark:border-[rgba(237,235,228,0.10)]";
88
const MUTED = "text-[#71706a] dark:text-[#8f8d84]";
99
const TRACK = "bg-black/[0.06] dark:bg-white/[0.07]";
1010

11+
const NAME_WIDTH = {
12+
sm: "w-24 sm:w-28",
13+
md: "w-28 sm:w-40",
14+
lg: "w-40 sm:w-56",
15+
} as const;
16+
1117
const countFormatter = new Intl.NumberFormat("en-US");
1218

1319
function LeaderboardRow({
@@ -29,10 +35,7 @@ function LeaderboardRow({
2935
<div className={cn("relative h-2.5 flex-1 overflow-hidden rounded-[3px]", TRACK)}>
3036
<div className="h-full rounded-[3px] bg-[#C6E853]" style={{ width: barWidth }} />
3137
</div>
32-
<span className="w-12 shrink-0 text-right font-mono text-[12px] tabular-nums">
33-
{countFormatter.format(item.value)}
34-
</span>
35-
<span className={cn("w-9 shrink-0 text-right font-mono text-[12px] tabular-nums", MUTED)}>
38+
<span className="w-11 shrink-0 text-right font-mono text-[12px] font-medium tabular-nums">
3639
{Math.round(item.pct * 100)}%
3740
</span>
3841
</li>
@@ -42,14 +45,14 @@ function LeaderboardRow({
4245
function Leaderboard({
4346
title,
4447
items,
45-
wide = false,
48+
size = "sm",
4649
}: {
4750
title: string;
4851
items: StackDistribution;
49-
wide?: boolean;
52+
size?: keyof typeof NAME_WIDTH;
5053
}) {
5154
const max = items[0]?.value ?? 0;
52-
const nameClass = wide ? "w-40 sm:w-56" : "w-24 sm:w-28";
55+
const nameClass = NAME_WIDTH[size];
5356

5457
return (
5558
<section aria-label={title}>
@@ -71,7 +74,7 @@ function Leaderboard({
7174
}
7275

7376
export function StackLeaderboard({ data }: { data: StackAnalyticsData }) {
74-
const { totalProjects, topStacks, frontend, backend, database, orm } = data;
77+
const { totalProjects, ecosystems, topStacks, frontend, backend, database, orm } = data;
7578
const hasData = totalProjects > 0;
7679

7780
return (
@@ -99,15 +102,16 @@ export function StackLeaderboard({ data }: { data: StackAnalyticsData }) {
99102
</header>
100103

101104
{hasData ? (
102-
<div className="px-5 py-6 sm:px-7">
103-
<Leaderboard title="Top stacks" items={topStacks} wide />
104-
<div className="mt-8 grid gap-x-10 gap-y-8 sm:grid-cols-2">
105+
<div className="space-y-8 px-5 py-6 sm:px-7">
106+
<Leaderboard title="Ecosystem" items={ecosystems} size="md" />
107+
<Leaderboard title="Top stacks" items={topStacks} size="lg" />
108+
<div className="grid gap-x-10 gap-y-8 sm:grid-cols-2">
105109
<Leaderboard title="Frontend" items={frontend} />
106110
<Leaderboard title="Backend" items={backend} />
107111
<Leaderboard title="Database" items={database} />
108112
<Leaderboard title="ORM" items={orm} />
109113
</div>
110-
<p className={cn("mt-8 border-t pt-4 font-mono text-[10px] uppercase tracking-[0.16em]", DIVIDER, MUTED)}>
114+
<p className={cn("border-t pt-4 font-mono text-[10px] uppercase tracking-[0.16em]", DIVIDER, MUTED)}>
111115
Aggregated from anonymous, opt-in CLI telemetry
112116
</p>
113117
</div>

apps/web/src/lib/analytics-aggregate.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type StackDistribution = StackDistributionItem[];
77

88
export type StackAnalyticsData = {
99
totalProjects: number;
10+
ecosystems: StackDistribution;
1011
topStacks: StackDistribution;
1112
frontend: StackDistribution;
1213
backend: StackDistribution;
@@ -18,41 +19,70 @@ type Dist = Record<string, number>;
1819

1920
export type RawAnalyticsStats = {
2021
totalProjects: number;
22+
ecosystem: Dist;
2123
frontend: Dist;
2224
backend: Dist;
2325
database: Dist;
2426
orm: Dist;
2527
stackCombinations?: Dist;
2628
};
2729

30+
const ECOSYSTEM_LABELS: Record<string, string> = {
31+
typescript: "TypeScript",
32+
"react-native": "React Native",
33+
rust: "Rust",
34+
python: "Python",
35+
go: "Go",
36+
java: "Java",
37+
dotnet: ".NET",
38+
elixir: "Elixir",
39+
};
40+
2841
export const EMPTY_STACK_ANALYTICS: StackAnalyticsData = {
2942
totalProjects: 0,
43+
ecosystems: [],
3044
topStacks: [],
3145
frontend: [],
3246
backend: [],
3347
database: [],
3448
orm: [],
3549
};
3650

37-
function toRankedDistribution(record: Dist | undefined, limit: number): StackDistribution {
51+
type RankOptions = {
52+
excludeNone?: boolean;
53+
excludeKeys?: string[];
54+
labels?: Record<string, string>;
55+
};
56+
57+
function toRankedDistribution(
58+
record: Dist | undefined,
59+
limit: number,
60+
options: RankOptions = {},
61+
): StackDistribution {
3862
if (!record) return [];
39-
const entries = Object.entries(record).filter(([name, value]) => name !== "" && value > 0);
63+
const excluded = new Set(options.excludeKeys ?? []);
64+
let entries = Object.entries(record).filter(
65+
([name, value]) => name !== "" && value > 0 && !excluded.has(name),
66+
);
67+
if (options.excludeNone) entries = entries.filter(([name]) => name !== "none");
68+
4069
const total = entries.reduce((sum, [, value]) => sum + value, 0);
4170
if (total === 0) return [];
4271

4372
return entries
44-
.map(([name, value]) => ({ name, value, pct: value / total }))
73+
.map(([name, value]) => ({ name: options.labels?.[name] ?? name, value, pct: value / total }))
4574
.sort((a, b) => b.value - a.value)
4675
.slice(0, limit);
4776
}
4877

4978
export function buildStackAnalytics(stats: RawAnalyticsStats): StackAnalyticsData {
5079
return {
5180
totalProjects: stats.totalProjects,
52-
topStacks: toRankedDistribution(stats.stackCombinations, 8),
53-
frontend: toRankedDistribution(stats.frontend, 6),
54-
backend: toRankedDistribution(stats.backend, 6),
55-
database: toRankedDistribution(stats.database, 6),
56-
orm: toRankedDistribution(stats.orm, 6),
81+
ecosystems: toRankedDistribution(stats.ecosystem, 8, { labels: ECOSYSTEM_LABELS }),
82+
topStacks: toRankedDistribution(stats.stackCombinations, 8, { excludeKeys: ["none + none"] }),
83+
frontend: toRankedDistribution(stats.frontend, 6, { excludeNone: true }),
84+
backend: toRankedDistribution(stats.backend, 6, { excludeNone: true }),
85+
database: toRankedDistribution(stats.database, 6, { excludeNone: true }),
86+
orm: toRankedDistribution(stats.orm, 6, { excludeNone: true }),
5787
};
5888
}

packages/backend/convex/analytics.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,24 @@ function getMajorVersion(version: string | undefined): string | undefined {
3737
return `v${clean.split(".")[0]}`;
3838
}
3939

40+
function mergeOptionStats(
41+
current: Record<string, Record<string, number>> | undefined,
42+
options: Record<string, string | string[]> | undefined,
43+
): Record<string, Record<string, number>> {
44+
const result: Record<string, Record<string, number>> = { ...current };
45+
if (!options) return result;
46+
for (const [category, value] of Object.entries(options)) {
47+
const values = Array.isArray(value) ? value : [value];
48+
const dist = { ...result[category] };
49+
for (const item of values) {
50+
if (!item) continue;
51+
dist[item] = (dist[item] ?? 0) + 1;
52+
}
53+
result[category] = dist;
54+
}
55+
return result;
56+
}
57+
4058
export const ingestEvent = internalMutation({
4159
args: {
4260
// Core
@@ -95,6 +113,7 @@ export const ingestEvent = internalMutation({
95113
cli_version: v.optional(v.string()),
96114
node_version: v.optional(v.string()),
97115
platform: v.optional(v.string()),
116+
options: v.optional(v.record(v.string(), v.union(v.string(), v.array(v.string())))),
98117
},
99118
returns: v.null(),
100119
handler: async (ctx, args) => {
@@ -176,6 +195,7 @@ export const ingestEvent = internalMutation({
176195
hourlyDistribution: incrementKey(existingStats.hourlyDistribution || {}, hourKey),
177196
stackCombinations: incrementKey(existingStats.stackCombinations || {}, stackKey),
178197
dbOrmCombinations: incrementKey(existingStats.dbOrmCombinations || {}, dbOrmKey),
198+
optionStats: mergeOptionStats(existingStats.optionStats, args.options),
179199
});
180200
} else {
181201
const emptyDist: Record<string, number> = {};
@@ -242,6 +262,7 @@ export const ingestEvent = internalMutation({
242262
hourlyDistribution: incrementKey(emptyDist, hourKey),
243263
stackCombinations: incrementKey(emptyDist, stackKey),
244264
dbOrmCombinations: incrementKey(emptyDist, dbOrmKey),
265+
optionStats: mergeOptionStats(undefined, args.options),
245266
});
246267
}
247268

@@ -329,6 +350,7 @@ export const getStats = query({
329350
hourlyDistribution: distributionValidator,
330351
stackCombinations: distributionValidator,
331352
dbOrmCombinations: distributionValidator,
353+
optionStats: v.record(v.string(), distributionValidator),
332354
}),
333355
v.null(),
334356
),
@@ -396,6 +418,7 @@ export const getStats = query({
396418
hourlyDistribution: stats.hourlyDistribution || {},
397419
stackCombinations: stats.stackCombinations || {},
398420
dbOrmCombinations: stats.dbOrmCombinations || {},
421+
optionStats: stats.optionStats ?? {},
399422
};
400423
},
401424
});
@@ -523,6 +546,7 @@ export const backfillStats = mutation({
523546
hourlyDistribution: { ...emptyDist },
524547
stackCombinations: { ...emptyDist },
525548
dbOrmCombinations: { ...emptyDist },
549+
optionStats: {} as Record<string, Record<string, number>>,
526550
};
527551

528552
const dailyCounts = new Map<string, number>();
@@ -601,6 +625,7 @@ export const backfillStats = mutation({
601625
stats.hourlyDistribution = incrementKey(stats.hourlyDistribution, hourKey);
602626
stats.stackCombinations = incrementKey(stats.stackCombinations, stackKey);
603627
stats.dbOrmCombinations = incrementKey(stats.dbOrmCombinations, dbOrmKey);
628+
stats.optionStats = mergeOptionStats(stats.optionStats, ev.options);
604629

605630
const date = new Date(ev._creationTime).toISOString().slice(0, 10);
606631
dailyCounts.set(date, (dailyCounts.get(date) || 0) + 1);

packages/backend/convex/http.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,112 @@ http.route({
7373
return new Response("Bad Request", { status: 400 });
7474
}
7575

76+
const EXTRA_OPTIONS_BY_ECOSYSTEM: Record<string, readonly string[]> = {
77+
typescript: ["vectorDb", "search", "i18n", "featureFlags", "rateLimit", "fileStorage", "analytics"],
78+
"react-native": [
79+
"mobileNavigation",
80+
"mobileUI",
81+
"mobileStorage",
82+
"mobileTesting",
83+
"mobilePush",
84+
"mobileOTA",
85+
"mobileDeepLinking",
86+
],
87+
rust: [
88+
"rustLogging",
89+
"rustErrorHandling",
90+
"rustCaching",
91+
"rustAuth",
92+
"rustRealtime",
93+
"rustMessageQueue",
94+
"rustObservability",
95+
"rustTemplating",
96+
],
97+
python: [
98+
"pythonWebFramework",
99+
"pythonOrm",
100+
"pythonValidation",
101+
"pythonAi",
102+
"pythonAuth",
103+
"pythonApi",
104+
"pythonTaskQueue",
105+
"pythonGraphql",
106+
"pythonQuality",
107+
"pythonTesting",
108+
"pythonCaching",
109+
"pythonRealtime",
110+
"pythonObservability",
111+
"pythonCli",
112+
],
113+
go: [
114+
"goWebFramework",
115+
"goOrm",
116+
"goApi",
117+
"goCli",
118+
"goLogging",
119+
"goAuth",
120+
"goTesting",
121+
"goRealtime",
122+
"goMessageQueue",
123+
"goCaching",
124+
"goConfig",
125+
"goObservability",
126+
],
127+
java: [
128+
"javaWebFramework",
129+
"javaBuildTool",
130+
"javaOrm",
131+
"javaAuth",
132+
"javaApi",
133+
"javaLogging",
134+
"javaLibraries",
135+
"javaTestingLibraries",
136+
],
137+
dotnet: [
138+
"dotnetWebFramework",
139+
"dotnetOrm",
140+
"dotnetAuth",
141+
"dotnetApi",
142+
"dotnetTesting",
143+
"dotnetJobQueue",
144+
"dotnetRealtime",
145+
"dotnetObservability",
146+
"dotnetValidation",
147+
"dotnetCaching",
148+
"dotnetDeploy",
149+
],
150+
elixir: [
151+
"elixirWebFramework",
152+
"elixirOrm",
153+
"elixirAuth",
154+
"elixirApi",
155+
"elixirRealtime",
156+
"elixirJobs",
157+
"elixirValidation",
158+
"elixirHttp",
159+
"elixirJson",
160+
"elixirEmail",
161+
"elixirCaching",
162+
"elixirObservability",
163+
"elixirTesting",
164+
"elixirQuality",
165+
"elixirDeploy",
166+
"elixirLibraries",
167+
],
168+
};
169+
const raw = body as Record<string, unknown>;
170+
const relevantKeys =
171+
EXTRA_OPTIONS_BY_ECOSYSTEM[typeof body.ecosystem === "string" ? body.ecosystem : ""] ?? [];
172+
const options: Record<string, string | string[]> = {};
173+
for (const key of relevantKeys) {
174+
const value = raw[key];
175+
if (typeof value === "string") {
176+
if (value) options[key] = value;
177+
} else if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
178+
if (value.length > 0) options[key] = value as string[];
179+
}
180+
}
181+
76182
const ingest = internal.analytics?.ingestEvent;
77183
if (ingest) {
78184
try {
@@ -133,6 +239,7 @@ http.route({
133239
cli_version: body.cli_version,
134240
node_version: body.node_version,
135241
platform: body.platform,
242+
options,
136243
});
137244
} catch (error) {
138245
console.error("Failed to ingest analytics:", error);

packages/backend/convex/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export default defineSchema({
7979
cli_version: v.optional(v.string()),
8080
node_version: v.optional(v.string()),
8181
platform: v.optional(v.string()),
82+
options: v.optional(v.record(v.string(), v.union(v.string(), v.array(v.string())))),
8283
}),
8384

8485
analyticsStats: defineTable({
@@ -144,6 +145,7 @@ export default defineSchema({
144145
hourlyDistribution: v.optional(distributionValidator),
145146
stackCombinations: v.optional(distributionValidator),
146147
dbOrmCombinations: v.optional(distributionValidator),
148+
optionStats: v.optional(v.record(v.string(), distributionValidator)),
147149
}),
148150

149151
analyticsDailyStats: defineTable({

0 commit comments

Comments
 (0)