@onemrvapublic/design-system-mcp-server 2.6.1 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/metrics.d.ts +69 -2
- package/dist/metrics.js +468 -66
- package/dist/metrics.js.map +1 -1
- package/dist/tool-components.js +8 -7
- package/dist/tool-components.js.map +1 -1
- package/dist/tool-devkit.js +11 -4
- package/dist/tool-devkit.js.map +1 -1
- package/dist/tool-outcome.d.ts +9 -0
- package/dist/tool-outcome.js +11 -0
- package/dist/tool-outcome.js.map +1 -0
- package/dist/tool-quality.js +5 -4
- package/dist/tool-quality.js.map +1 -1
- package/dist/tool-releases.js +12 -1
- package/dist/tool-releases.js.map +1 -1
- package/mcp-data/sync-status.json +2 -2
- package/package.json +2 -1
package/dist/metrics.js
CHANGED
|
@@ -1,21 +1,38 @@
|
|
|
1
1
|
// In-process usage metrics for hosted-mode KPIs. Kept in memory; persist to a
|
|
2
2
|
// JSON file (MCP_METRICS_FILE) so they survive restarts, and/or scrape /metrics
|
|
3
3
|
// into Prometheus for history.
|
|
4
|
+
import { outcomeOf } from './tool-outcome.js';
|
|
4
5
|
const toolStats = new Map();
|
|
5
6
|
const clientStats = new Map();
|
|
6
7
|
const projectStats = new Map();
|
|
7
8
|
// Keyed by lowercased text; the stored entry keeps the first-seen original.
|
|
8
9
|
const questionStats = new Map();
|
|
10
|
+
// Same keys, only for searches that matched nothing.
|
|
11
|
+
const unmetSearches = new Map();
|
|
12
|
+
// Keyed by UTC date (YYYY-MM-DD).
|
|
13
|
+
const dayStats = new Map();
|
|
14
|
+
const componentStats = new Map();
|
|
9
15
|
let totalCalls = 0;
|
|
10
16
|
let totalErrors = 0;
|
|
17
|
+
let totalCodeBlocks = 0;
|
|
18
|
+
let totalCodeLines = 0;
|
|
19
|
+
let totalReviews = 0;
|
|
20
|
+
let totalIssues = 0;
|
|
21
|
+
let totalUnmetSearches = 0;
|
|
11
22
|
const QUESTION_INPUT_MAX = 300; // anything longer is a code payload, never record it
|
|
12
23
|
const QUESTION_STORED_MAX = 120;
|
|
13
24
|
const QUESTION_CAP_DEFAULT = 500;
|
|
14
25
|
const TOP_QUESTIONS_LIMIT = 50;
|
|
26
|
+
const UNMET_CAP = 300;
|
|
27
|
+
const TOP_COMPONENTS_LIMIT = 30;
|
|
28
|
+
const DAYS_KEPT = 90;
|
|
29
|
+
// Upper bounds in ms; p95 is reported as the bound of the bucket it falls in.
|
|
30
|
+
const LATENCY_BUCKETS_MS = [10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
|
|
15
31
|
// Caps on client-controlled label cardinality.
|
|
16
32
|
const MAX_TRACKED_TOOLS = 100;
|
|
17
33
|
const MAX_TRACKED_CLIENTS = 50;
|
|
18
34
|
const MAX_TRACKED_PROJECTS = 300;
|
|
35
|
+
const MAX_TRACKED_COMPONENTS = 200;
|
|
19
36
|
const LABEL_VALUE_MAX = 120;
|
|
20
37
|
const OVERFLOW_LABEL = '_other';
|
|
21
38
|
let questionCap = QUESTION_CAP_DEFAULT;
|
|
@@ -31,17 +48,185 @@ function boundedKey(map, key, cap) {
|
|
|
31
48
|
}
|
|
32
49
|
return OVERFLOW_LABEL;
|
|
33
50
|
}
|
|
34
|
-
|
|
51
|
+
// Tools whose answer is code for the caller's project; lookups like get_component don't count.
|
|
52
|
+
export const CODE_TOOLS = new Set([
|
|
53
|
+
'generate_component_usage',
|
|
54
|
+
'generate_form',
|
|
55
|
+
'migrate_to_ds',
|
|
56
|
+
'migrate_file',
|
|
57
|
+
'get_started',
|
|
58
|
+
'ds_install',
|
|
59
|
+
]);
|
|
60
|
+
const NOT_CODE = new Set(['bash', 'sh', 'shell', 'console', 'text', 'txt']);
|
|
61
|
+
// Fenced blocks and their non-blank lines, minus shell and plain text; an unclosed block runs to the end.
|
|
62
|
+
export function countCode(text) {
|
|
63
|
+
let blocks = 0;
|
|
64
|
+
let lines = 0;
|
|
65
|
+
let fence = null;
|
|
66
|
+
for (const line of text.split(/\r?\n/)) {
|
|
67
|
+
const marker = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)/.exec(line);
|
|
68
|
+
if (!fence) {
|
|
69
|
+
if (marker) {
|
|
70
|
+
const counted = !NOT_CODE.has(marker[2].toLowerCase());
|
|
71
|
+
fence = { char: marker[1][0], length: marker[1].length, counted };
|
|
72
|
+
blocks += counted ? 1 : 0;
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (marker &&
|
|
77
|
+
marker[1][0] === fence.char &&
|
|
78
|
+
marker[1].length >= fence.length &&
|
|
79
|
+
line.trim() === marker[1]) {
|
|
80
|
+
fence = null;
|
|
81
|
+
}
|
|
82
|
+
else if (fence.counted && line.trim()) {
|
|
83
|
+
lines += 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { blocks, lines };
|
|
87
|
+
}
|
|
88
|
+
function resultText(result) {
|
|
89
|
+
const content = result?.content;
|
|
90
|
+
if (!Array.isArray(content)) {
|
|
91
|
+
return '';
|
|
92
|
+
}
|
|
93
|
+
return content
|
|
94
|
+
.map(c => c && typeof c.text === 'string'
|
|
95
|
+
? c.text
|
|
96
|
+
: '')
|
|
97
|
+
.join('\n');
|
|
98
|
+
}
|
|
99
|
+
function latencyBucket(ms) {
|
|
100
|
+
const i = LATENCY_BUCKETS_MS.findIndex(bound => ms <= bound);
|
|
101
|
+
return i === -1 ? LATENCY_BUCKETS_MS.length : i;
|
|
102
|
+
}
|
|
103
|
+
function emptyLatency() {
|
|
104
|
+
return new Array(LATENCY_BUCKETS_MS.length + 1).fill(0);
|
|
105
|
+
}
|
|
106
|
+
// Capped by the slowest call: the bucket bound can be far above it.
|
|
107
|
+
function p95(latency, maxMs) {
|
|
108
|
+
const total = latency.reduce((n, c) => n + c, 0);
|
|
109
|
+
if (total === 0) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const target = Math.ceil(total * 0.95);
|
|
113
|
+
let seen = 0;
|
|
114
|
+
for (let i = 0; i < latency.length; i++) {
|
|
115
|
+
seen += latency[i];
|
|
116
|
+
if (seen >= target) {
|
|
117
|
+
return Math.round(Math.min(LATENCY_BUCKETS_MS[i] ?? maxMs, maxMs));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return Math.round(maxMs);
|
|
121
|
+
}
|
|
122
|
+
// The version without its range operator: "^22.7.1" -> "22.7.1".
|
|
123
|
+
function cleanVersion(value) {
|
|
124
|
+
if (typeof value !== 'string') {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const m = /\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.]+)?/.exec(value);
|
|
128
|
+
return m ? m[0].slice(0, 40) : null;
|
|
129
|
+
}
|
|
130
|
+
function cleanMajor(value) {
|
|
131
|
+
return typeof value === 'number' &&
|
|
132
|
+
Number.isInteger(value) &&
|
|
133
|
+
value > 0 &&
|
|
134
|
+
value < 1000
|
|
135
|
+
? value
|
|
136
|
+
: null;
|
|
137
|
+
}
|
|
138
|
+
// Errors and warnings a lint or review reported; null when the call was neither.
|
|
139
|
+
function reviewIssues(outcome) {
|
|
140
|
+
const issues = outcome?.issues;
|
|
141
|
+
return typeof issues === 'number' && Number.isFinite(issues)
|
|
142
|
+
? Math.max(0, Math.floor(issues))
|
|
143
|
+
: null;
|
|
144
|
+
}
|
|
145
|
+
function shiftDate(date, days) {
|
|
146
|
+
const d = new Date(`${date}T00:00:00Z`);
|
|
147
|
+
d.setUTCDate(d.getUTCDate() + days);
|
|
148
|
+
return d.toISOString().slice(0, 10);
|
|
149
|
+
}
|
|
150
|
+
function today() {
|
|
151
|
+
return new Date().toISOString().slice(0, 10);
|
|
152
|
+
}
|
|
153
|
+
function pruneDays(day) {
|
|
154
|
+
const cutoff = shiftDate(day, -(DAYS_KEPT - 1));
|
|
155
|
+
for (const date of [...dayStats.keys()]) {
|
|
156
|
+
if (date < cutoff) {
|
|
157
|
+
dayStats.delete(date);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function recordDay(now, ok, codeLines, projectKey) {
|
|
162
|
+
const date = now.slice(0, 10);
|
|
163
|
+
let day = dayStats.get(date);
|
|
164
|
+
if (!day) {
|
|
165
|
+
day = { calls: 0, errors: 0, codeLines: 0, projects: new Set() };
|
|
166
|
+
dayStats.set(date, day);
|
|
167
|
+
pruneDays(date);
|
|
168
|
+
}
|
|
169
|
+
day.calls += 1;
|
|
170
|
+
if (!ok) {
|
|
171
|
+
day.errors += 1;
|
|
172
|
+
}
|
|
173
|
+
day.codeLines += codeLines;
|
|
174
|
+
if (projectKey) {
|
|
175
|
+
day.projects.add(projectKey);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function recordComponent(component, projectKey, now) {
|
|
179
|
+
const name = clampLabel(component.trim().toLowerCase());
|
|
180
|
+
if (!name) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const key = boundedKey(componentStats, name, MAX_TRACKED_COMPONENTS);
|
|
184
|
+
let stat = componentStats.get(key);
|
|
185
|
+
if (!stat) {
|
|
186
|
+
stat = { generated: 0, projects: new Set(), lastGeneratedAt: now };
|
|
187
|
+
componentStats.set(key, stat);
|
|
188
|
+
}
|
|
189
|
+
stat.generated += 1;
|
|
190
|
+
stat.lastGeneratedAt = now;
|
|
191
|
+
if (projectKey) {
|
|
192
|
+
stat.projects.add(projectKey);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
export function recordToolCall(name, durationMs, ok, client, project, result) {
|
|
35
196
|
const now = new Date().toISOString();
|
|
36
197
|
const toolKey = boundedKey(toolStats, clampLabel(name), MAX_TRACKED_TOOLS);
|
|
37
198
|
const clientKey = boundedKey(clientStats, clampLabel(client), MAX_TRACKED_CLIENTS);
|
|
199
|
+
const projectKey = project
|
|
200
|
+
? boundedKey(projectStats, clampLabel(project), MAX_TRACKED_PROJECTS)
|
|
201
|
+
: undefined;
|
|
202
|
+
const code = ok && CODE_TOOLS.has(name)
|
|
203
|
+
? countCode(resultText(result))
|
|
204
|
+
: { blocks: 0, lines: 0 };
|
|
205
|
+
const outcome = outcomeOf(result);
|
|
206
|
+
const issues = ok ? reviewIssues(outcome) : null;
|
|
38
207
|
totalCalls += 1;
|
|
39
208
|
if (!ok) {
|
|
40
209
|
totalErrors += 1;
|
|
41
210
|
}
|
|
211
|
+
totalCodeBlocks += code.blocks;
|
|
212
|
+
totalCodeLines += code.lines;
|
|
213
|
+
if (issues !== null) {
|
|
214
|
+
totalReviews += 1;
|
|
215
|
+
totalIssues += issues;
|
|
216
|
+
}
|
|
42
217
|
let stat = toolStats.get(toolKey);
|
|
43
218
|
if (!stat) {
|
|
44
|
-
stat = {
|
|
219
|
+
stat = {
|
|
220
|
+
calls: 0,
|
|
221
|
+
errors: 0,
|
|
222
|
+
totalMs: 0,
|
|
223
|
+
maxMs: 0,
|
|
224
|
+
lastUsedAt: null,
|
|
225
|
+
codeBlocks: 0,
|
|
226
|
+
codeLines: 0,
|
|
227
|
+
latency: emptyLatency(),
|
|
228
|
+
latencySumMs: 0,
|
|
229
|
+
};
|
|
45
230
|
toolStats.set(toolKey, stat);
|
|
46
231
|
}
|
|
47
232
|
stat.calls += 1;
|
|
@@ -53,6 +238,10 @@ export function recordToolCall(name, durationMs, ok, client, project) {
|
|
|
53
238
|
stat.maxMs = durationMs;
|
|
54
239
|
}
|
|
55
240
|
stat.lastUsedAt = now;
|
|
241
|
+
stat.codeBlocks += code.blocks;
|
|
242
|
+
stat.codeLines += code.lines;
|
|
243
|
+
stat.latency[latencyBucket(durationMs)] += 1;
|
|
244
|
+
stat.latencySumMs += durationMs;
|
|
56
245
|
let cli = clientStats.get(clientKey);
|
|
57
246
|
if (!cli) {
|
|
58
247
|
cli = { calls: 0, errors: 0, lastSeenAt: null };
|
|
@@ -63,11 +252,20 @@ export function recordToolCall(name, durationMs, ok, client, project) {
|
|
|
63
252
|
cli.errors += 1;
|
|
64
253
|
}
|
|
65
254
|
cli.lastSeenAt = now;
|
|
66
|
-
if (
|
|
67
|
-
const projectKey = boundedKey(projectStats, clampLabel(project), MAX_TRACKED_PROJECTS);
|
|
255
|
+
if (projectKey) {
|
|
68
256
|
let pr = projectStats.get(projectKey);
|
|
69
257
|
if (!pr) {
|
|
70
|
-
pr = {
|
|
258
|
+
pr = {
|
|
259
|
+
calls: 0,
|
|
260
|
+
errors: 0,
|
|
261
|
+
lastSeenAt: null,
|
|
262
|
+
codeBlocks: 0,
|
|
263
|
+
codeLines: 0,
|
|
264
|
+
reviews: 0,
|
|
265
|
+
issues: 0,
|
|
266
|
+
dsVersion: null,
|
|
267
|
+
angularMajor: null,
|
|
268
|
+
};
|
|
71
269
|
projectStats.set(projectKey, pr);
|
|
72
270
|
}
|
|
73
271
|
pr.calls += 1;
|
|
@@ -75,7 +273,20 @@ export function recordToolCall(name, durationMs, ok, client, project) {
|
|
|
75
273
|
pr.errors += 1;
|
|
76
274
|
}
|
|
77
275
|
pr.lastSeenAt = now;
|
|
276
|
+
pr.codeBlocks += code.blocks;
|
|
277
|
+
pr.codeLines += code.lines;
|
|
278
|
+
if (issues !== null) {
|
|
279
|
+
pr.reviews += 1;
|
|
280
|
+
pr.issues += issues;
|
|
281
|
+
}
|
|
282
|
+
// Latest report wins, so an upgrade shows up on the next call.
|
|
283
|
+
pr.dsVersion = cleanVersion(outcome?.dsVersion) ?? pr.dsVersion;
|
|
284
|
+
pr.angularMajor = cleanMajor(outcome?.angularMajor) ?? pr.angularMajor;
|
|
78
285
|
}
|
|
286
|
+
if (ok && outcome?.component) {
|
|
287
|
+
recordComponent(outcome.component, projectKey, now);
|
|
288
|
+
}
|
|
289
|
+
recordDay(now, ok, code.lines, projectKey);
|
|
79
290
|
}
|
|
80
291
|
// Derive a repo/project id from tool args: an explicit `project`, else the
|
|
81
292
|
// `name` in a package.json passed to install/upgrade tools. Sanitized + capped.
|
|
@@ -122,10 +333,10 @@ export const QUESTION_FIELDS = {
|
|
|
122
333
|
compare_components: ['nameA', 'nameB'],
|
|
123
334
|
explain_token_diff: ['tokenA', 'tokenB'],
|
|
124
335
|
};
|
|
125
|
-
function
|
|
336
|
+
function evictColdest(map) {
|
|
126
337
|
let coldestKey = null;
|
|
127
338
|
let coldest = null;
|
|
128
|
-
for (const [key, q] of
|
|
339
|
+
for (const [key, q] of map) {
|
|
129
340
|
if (!coldest ||
|
|
130
341
|
q.count < coldest.count ||
|
|
131
342
|
(q.count === coldest.count && q.lastAskedAt < coldest.lastAskedAt)) {
|
|
@@ -134,14 +345,32 @@ function evictColdestQuestion() {
|
|
|
134
345
|
}
|
|
135
346
|
}
|
|
136
347
|
if (coldestKey !== null) {
|
|
137
|
-
|
|
348
|
+
map.delete(coldestKey);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function countText(map, cap, text, tool, now) {
|
|
352
|
+
const key = text.toLowerCase();
|
|
353
|
+
const existing = map.get(key);
|
|
354
|
+
if (existing) {
|
|
355
|
+
existing.count += 1;
|
|
356
|
+
existing.lastAskedAt = now;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (map.size >= cap) {
|
|
360
|
+
evictColdest(map);
|
|
138
361
|
}
|
|
362
|
+
map.set(key, { text, tool, count: 1, lastAskedAt: now });
|
|
139
363
|
}
|
|
140
|
-
|
|
364
|
+
// Pass the result too: a search that matched nothing is demand the DS doesn't cover.
|
|
365
|
+
export function recordQuestion(tool, args, result) {
|
|
141
366
|
const fields = QUESTION_FIELDS[tool];
|
|
142
367
|
if (!fields || typeof args !== 'object' || args === null) {
|
|
143
368
|
return;
|
|
144
369
|
}
|
|
370
|
+
const unmet = outcomeOf(result)?.matches === 0;
|
|
371
|
+
if (unmet) {
|
|
372
|
+
totalUnmetSearches += 1;
|
|
373
|
+
}
|
|
145
374
|
const record = args;
|
|
146
375
|
for (const field of fields) {
|
|
147
376
|
const value = record[field];
|
|
@@ -153,19 +382,47 @@ export function recordQuestion(tool, args) {
|
|
|
153
382
|
continue;
|
|
154
383
|
}
|
|
155
384
|
const text = trimmed.slice(0, QUESTION_STORED_MAX);
|
|
156
|
-
const key = text.toLowerCase();
|
|
157
385
|
const now = new Date().toISOString();
|
|
158
|
-
|
|
159
|
-
if (
|
|
160
|
-
|
|
161
|
-
existing.lastAskedAt = now;
|
|
162
|
-
continue;
|
|
386
|
+
countText(questionStats, questionCap, text, tool, now);
|
|
387
|
+
if (unmet) {
|
|
388
|
+
countText(unmetSearches, UNMET_CAP, text, tool, now);
|
|
163
389
|
}
|
|
164
|
-
|
|
165
|
-
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function activeProjects(day, days) {
|
|
393
|
+
const from = shiftDate(day, -(days - 1));
|
|
394
|
+
const seen = new Set();
|
|
395
|
+
for (const [date, stat] of dayStats) {
|
|
396
|
+
if (date >= from && date <= day) {
|
|
397
|
+
stat.projects.forEach(p => seen.add(p));
|
|
166
398
|
}
|
|
167
|
-
questionStats.set(key, { text, tool, count: 1, lastAskedAt: now });
|
|
168
399
|
}
|
|
400
|
+
return seen.size;
|
|
401
|
+
}
|
|
402
|
+
function dailySeries(day) {
|
|
403
|
+
const first = [...dayStats.keys()].sort()[0];
|
|
404
|
+
if (!first) {
|
|
405
|
+
return [];
|
|
406
|
+
}
|
|
407
|
+
const series = [];
|
|
408
|
+
const oldest = shiftDate(day, -(DAYS_KEPT - 1));
|
|
409
|
+
for (let date = first > oldest ? first : oldest; date <= day; date = shiftDate(date, 1)) {
|
|
410
|
+
const stat = dayStats.get(date);
|
|
411
|
+
series.push({
|
|
412
|
+
date,
|
|
413
|
+
calls: stat?.calls ?? 0,
|
|
414
|
+
errors: stat?.errors ?? 0,
|
|
415
|
+
codeLines: stat?.codeLines ?? 0,
|
|
416
|
+
activeProjects: stat?.projects.size ?? 0,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
return series;
|
|
420
|
+
}
|
|
421
|
+
function topTexts(map) {
|
|
422
|
+
return [...map.values()]
|
|
423
|
+
.sort((a, b) => b.count - a.count)
|
|
424
|
+
.slice(0, TOP_QUESTIONS_LIMIT)
|
|
425
|
+
.map(q => ({ ...q }));
|
|
169
426
|
}
|
|
170
427
|
export function getMetricsSnapshot() {
|
|
171
428
|
const tools = [...toolStats.entries()]
|
|
@@ -175,7 +432,10 @@ export function getMetricsSnapshot() {
|
|
|
175
432
|
errors: s.errors,
|
|
176
433
|
avgMs: s.calls > 0 ? Math.round(s.totalMs / s.calls) : 0,
|
|
177
434
|
maxMs: Math.round(s.maxMs),
|
|
435
|
+
p95Ms: p95(s.latency, s.maxMs),
|
|
178
436
|
lastUsedAt: s.lastUsedAt,
|
|
437
|
+
codeBlocks: s.codeBlocks,
|
|
438
|
+
codeLines: s.codeLines,
|
|
179
439
|
}))
|
|
180
440
|
.sort((a, b) => b.calls - a.calls);
|
|
181
441
|
const clients = [...clientStats.entries()]
|
|
@@ -192,21 +452,50 @@ export function getMetricsSnapshot() {
|
|
|
192
452
|
calls: s.calls,
|
|
193
453
|
errors: s.errors,
|
|
194
454
|
lastSeenAt: s.lastSeenAt,
|
|
455
|
+
codeBlocks: s.codeBlocks,
|
|
456
|
+
codeLines: s.codeLines,
|
|
457
|
+
reviews: s.reviews,
|
|
458
|
+
issues: s.issues,
|
|
459
|
+
dsVersion: s.dsVersion,
|
|
460
|
+
angularMajor: s.angularMajor,
|
|
195
461
|
}))
|
|
196
462
|
.sort((a, b) => b.calls - a.calls);
|
|
197
|
-
const
|
|
198
|
-
.
|
|
199
|
-
|
|
200
|
-
.
|
|
463
|
+
const components = [...componentStats.entries()]
|
|
464
|
+
.map(([component, s]) => ({
|
|
465
|
+
component,
|
|
466
|
+
generated: s.generated,
|
|
467
|
+
projects: s.projects.size,
|
|
468
|
+
lastGeneratedAt: s.lastGeneratedAt,
|
|
469
|
+
}))
|
|
470
|
+
.sort((a, b) => b.generated - a.generated)
|
|
471
|
+
.slice(0, TOP_COMPONENTS_LIMIT);
|
|
472
|
+
const allLatency = emptyLatency();
|
|
473
|
+
let slowest = 0;
|
|
474
|
+
for (const s of toolStats.values()) {
|
|
475
|
+
s.latency.forEach((count, i) => (allLatency[i] += count));
|
|
476
|
+
slowest = Math.max(slowest, s.maxMs);
|
|
477
|
+
}
|
|
478
|
+
const day = today();
|
|
201
479
|
return {
|
|
202
480
|
totalCalls,
|
|
203
481
|
totalErrors,
|
|
482
|
+
totalCodeBlocks,
|
|
483
|
+
totalCodeLines,
|
|
484
|
+
totalReviews,
|
|
485
|
+
totalIssues,
|
|
486
|
+
totalUnmetSearches,
|
|
487
|
+
p95Ms: p95(allLatency, slowest),
|
|
488
|
+
activeProjects7d: activeProjects(day, 7),
|
|
489
|
+
activeProjects30d: activeProjects(day, 30),
|
|
204
490
|
distinctTools: toolStats.size,
|
|
205
491
|
distinctProjects: projectStats.size,
|
|
206
492
|
tools,
|
|
207
493
|
clients,
|
|
208
494
|
projects,
|
|
209
|
-
topQuestions,
|
|
495
|
+
topQuestions: topTexts(questionStats),
|
|
496
|
+
unmetSearches: topTexts(unmetSearches),
|
|
497
|
+
components,
|
|
498
|
+
daily: dailySeries(day),
|
|
210
499
|
};
|
|
211
500
|
}
|
|
212
501
|
// Prometheus label escaping (backslash before newline/quote).
|
|
@@ -246,6 +535,43 @@ export function renderPrometheusMetrics() {
|
|
|
246
535
|
for (const p of snap.projects) {
|
|
247
536
|
lines.push(`mcp_project_calls_total{project="${escapeLabel(p.project)}"} ${p.calls}`);
|
|
248
537
|
}
|
|
538
|
+
lines.push('# HELP mcp_code_lines_total Code lines returned by the code-generating tools.', '# TYPE mcp_code_lines_total counter', `mcp_code_lines_total ${snap.totalCodeLines}`, '# HELP mcp_code_blocks_total Code blocks returned by the code-generating tools.', '# TYPE mcp_code_blocks_total counter', `mcp_code_blocks_total ${snap.totalCodeBlocks}`, '# HELP mcp_project_code_lines_total Code lines returned, per repo/project.', '# TYPE mcp_project_code_lines_total counter');
|
|
539
|
+
for (const p of snap.projects) {
|
|
540
|
+
lines.push(`mcp_project_code_lines_total{project="${escapeLabel(p.project)}"} ${p.codeLines}`);
|
|
541
|
+
}
|
|
542
|
+
lines.push('# HELP mcp_project_code_blocks_total Code blocks returned, per repo/project.', '# TYPE mcp_project_code_blocks_total counter');
|
|
543
|
+
for (const p of snap.projects) {
|
|
544
|
+
lines.push(`mcp_project_code_blocks_total{project="${escapeLabel(p.project)}"} ${p.codeBlocks}`);
|
|
545
|
+
}
|
|
546
|
+
lines.push('# HELP mcp_tool_code_lines_total Code lines returned, per tool.', '# TYPE mcp_tool_code_lines_total counter');
|
|
547
|
+
for (const t of snap.tools) {
|
|
548
|
+
lines.push(`mcp_tool_code_lines_total{tool="${escapeLabel(t.tool)}"} ${t.codeLines}`);
|
|
549
|
+
}
|
|
550
|
+
lines.push('# HELP mcp_tool_duration_ms Tool call duration in milliseconds.', '# TYPE mcp_tool_duration_ms histogram');
|
|
551
|
+
for (const [tool, s] of toolStats) {
|
|
552
|
+
const label = escapeLabel(tool);
|
|
553
|
+
let cumulative = 0;
|
|
554
|
+
LATENCY_BUCKETS_MS.forEach((bound, i) => {
|
|
555
|
+
cumulative += s.latency[i];
|
|
556
|
+
lines.push(`mcp_tool_duration_ms_bucket{tool="${label}",le="${bound}"} ${cumulative}`);
|
|
557
|
+
});
|
|
558
|
+
cumulative += s.latency[LATENCY_BUCKETS_MS.length];
|
|
559
|
+
lines.push(`mcp_tool_duration_ms_bucket{tool="${label}",le="+Inf"} ${cumulative}`, `mcp_tool_duration_ms_sum{tool="${label}"} ${s.latencySumMs}`, `mcp_tool_duration_ms_count{tool="${label}"} ${cumulative}`);
|
|
560
|
+
}
|
|
561
|
+
lines.push('# HELP mcp_reviews_total lint_ds_usage and review_implementation runs.', '# TYPE mcp_reviews_total counter', `mcp_reviews_total ${snap.totalReviews}`, '# HELP mcp_issues_found_total DS usage errors and warnings those runs reported.', '# TYPE mcp_issues_found_total counter', `mcp_issues_found_total ${snap.totalIssues}`, '# HELP mcp_project_issues_found_total DS usage issues reported, per repo/project.', '# TYPE mcp_project_issues_found_total counter');
|
|
562
|
+
for (const p of snap.projects) {
|
|
563
|
+
lines.push(`mcp_project_issues_found_total{project="${escapeLabel(p.project)}"} ${p.issues}`);
|
|
564
|
+
}
|
|
565
|
+
lines.push('# HELP mcp_unmet_searches_total Searches and suggestions that matched nothing.', '# TYPE mcp_unmet_searches_total counter', `mcp_unmet_searches_total ${snap.totalUnmetSearches}`, '# HELP mcp_active_projects Projects with at least one call in the window.', '# TYPE mcp_active_projects gauge', `mcp_active_projects{window="7d"} ${snap.activeProjects7d}`, `mcp_active_projects{window="30d"} ${snap.activeProjects30d}`, '# HELP mcp_component_generated_total Generator calls per DS component.', '# TYPE mcp_component_generated_total counter');
|
|
566
|
+
for (const [component, s] of componentStats) {
|
|
567
|
+
lines.push(`mcp_component_generated_total{component="${escapeLabel(component)}"} ${s.generated}`);
|
|
568
|
+
}
|
|
569
|
+
lines.push('# HELP mcp_project_info DS version and Angular major last reported per repo/project.', '# TYPE mcp_project_info gauge');
|
|
570
|
+
for (const p of snap.projects) {
|
|
571
|
+
if (p.dsVersion || p.angularMajor) {
|
|
572
|
+
lines.push(`mcp_project_info{project="${escapeLabel(p.project)}",ds_version="${escapeLabel(p.dsVersion ?? '')}",angular_major="${p.angularMajor ?? ''}"} 1`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
249
575
|
return `${lines.join('\n')}\n`;
|
|
250
576
|
}
|
|
251
577
|
export function serializeMetrics() {
|
|
@@ -253,10 +579,33 @@ export function serializeMetrics() {
|
|
|
253
579
|
version: 1,
|
|
254
580
|
totalCalls,
|
|
255
581
|
totalErrors,
|
|
582
|
+
totalCodeBlocks,
|
|
583
|
+
totalCodeLines,
|
|
584
|
+
totalReviews,
|
|
585
|
+
totalIssues,
|
|
586
|
+
totalUnmetSearches,
|
|
256
587
|
tools: [...toolStats.entries()],
|
|
257
588
|
clients: [...clientStats.entries()],
|
|
258
589
|
projects: [...projectStats.entries()],
|
|
259
590
|
questions: [...questionStats.entries()],
|
|
591
|
+
unmetSearches: [...unmetSearches.entries()],
|
|
592
|
+
days: [...dayStats.entries()].map(([date, d]) => [
|
|
593
|
+
date,
|
|
594
|
+
{
|
|
595
|
+
calls: d.calls,
|
|
596
|
+
errors: d.errors,
|
|
597
|
+
codeLines: d.codeLines,
|
|
598
|
+
projects: [...d.projects],
|
|
599
|
+
},
|
|
600
|
+
]),
|
|
601
|
+
components: [...componentStats.entries()].map(([component, c]) => [
|
|
602
|
+
component,
|
|
603
|
+
{
|
|
604
|
+
generated: c.generated,
|
|
605
|
+
projects: [...c.projects],
|
|
606
|
+
lastGeneratedAt: c.lastGeneratedAt,
|
|
607
|
+
},
|
|
608
|
+
]),
|
|
260
609
|
};
|
|
261
610
|
}
|
|
262
611
|
function num(v) {
|
|
@@ -265,6 +614,33 @@ function num(v) {
|
|
|
265
614
|
function strOrNull(v) {
|
|
266
615
|
return typeof v === 'string' ? v : null;
|
|
267
616
|
}
|
|
617
|
+
function strings(v) {
|
|
618
|
+
return Array.isArray(v)
|
|
619
|
+
? v.filter((s) => typeof s === 'string')
|
|
620
|
+
: [];
|
|
621
|
+
}
|
|
622
|
+
// A histogram with another bucket layout can't be mapped; it restarts empty.
|
|
623
|
+
function latencyCounts(v) {
|
|
624
|
+
return Array.isArray(v) && v.length === LATENCY_BUCKETS_MS.length + 1
|
|
625
|
+
? v.map(num)
|
|
626
|
+
: emptyLatency();
|
|
627
|
+
}
|
|
628
|
+
function entries(v) {
|
|
629
|
+
return (Array.isArray(v) ? v : []).filter((e) => Array.isArray(e) && typeof e[0] === 'string' && !!e[1]);
|
|
630
|
+
}
|
|
631
|
+
function loadTexts(map, v) {
|
|
632
|
+
for (const [key, raw] of entries(v)) {
|
|
633
|
+
const q = raw;
|
|
634
|
+
if (typeof q.text === 'string' && typeof q.tool === 'string') {
|
|
635
|
+
map.set(key, {
|
|
636
|
+
text: q.text,
|
|
637
|
+
tool: q.tool,
|
|
638
|
+
count: num(q.count),
|
|
639
|
+
lastAskedAt: strOrNull(q.lastAskedAt) ?? new Date().toISOString(),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
268
644
|
// Restores state from parsed JSON. Defensive: bad shapes are skipped, not thrown.
|
|
269
645
|
export function loadMetrics(raw) {
|
|
270
646
|
if (!raw || typeof raw !== 'object') {
|
|
@@ -274,67 +650,93 @@ export function loadMetrics(raw) {
|
|
|
274
650
|
if (d.version !== 1) {
|
|
275
651
|
return false;
|
|
276
652
|
}
|
|
277
|
-
|
|
278
|
-
clientStats.clear();
|
|
279
|
-
projectStats.clear();
|
|
280
|
-
questionStats.clear();
|
|
653
|
+
clearState();
|
|
281
654
|
totalCalls = num(d.totalCalls);
|
|
282
655
|
totalErrors = num(d.totalErrors);
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
656
|
+
totalCodeBlocks = num(d.totalCodeBlocks);
|
|
657
|
+
totalCodeLines = num(d.totalCodeLines);
|
|
658
|
+
totalReviews = num(d.totalReviews);
|
|
659
|
+
totalIssues = num(d.totalIssues);
|
|
660
|
+
totalUnmetSearches = num(d.totalUnmetSearches);
|
|
661
|
+
for (const [key, value] of entries(d.tools)) {
|
|
662
|
+
const v = value;
|
|
663
|
+
toolStats.set(key, {
|
|
664
|
+
calls: num(v.calls),
|
|
665
|
+
errors: num(v.errors),
|
|
666
|
+
totalMs: num(v.totalMs),
|
|
667
|
+
maxMs: num(v.maxMs),
|
|
668
|
+
lastUsedAt: strOrNull(v.lastUsedAt),
|
|
669
|
+
codeBlocks: num(v.codeBlocks),
|
|
670
|
+
codeLines: num(v.codeLines),
|
|
671
|
+
latency: latencyCounts(v.latency),
|
|
672
|
+
latencySumMs: num(v.latencySumMs),
|
|
673
|
+
});
|
|
294
674
|
}
|
|
295
|
-
for (const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
675
|
+
for (const [key, value] of entries(d.clients)) {
|
|
676
|
+
const v = value;
|
|
677
|
+
clientStats.set(key, {
|
|
678
|
+
calls: num(v.calls),
|
|
679
|
+
errors: num(v.errors),
|
|
680
|
+
lastSeenAt: strOrNull(v.lastSeenAt),
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
for (const [key, value] of entries(d.projects)) {
|
|
684
|
+
const v = value;
|
|
685
|
+
projectStats.set(key, {
|
|
686
|
+
calls: num(v.calls),
|
|
687
|
+
errors: num(v.errors),
|
|
688
|
+
lastSeenAt: strOrNull(v.lastSeenAt),
|
|
689
|
+
codeBlocks: num(v.codeBlocks),
|
|
690
|
+
codeLines: num(v.codeLines),
|
|
691
|
+
reviews: num(v.reviews),
|
|
692
|
+
issues: num(v.issues),
|
|
693
|
+
dsVersion: cleanVersion(v.dsVersion),
|
|
694
|
+
angularMajor: cleanMajor(v.angularMajor),
|
|
695
|
+
});
|
|
304
696
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
697
|
+
loadTexts(questionStats, d.questions);
|
|
698
|
+
loadTexts(unmetSearches, d.unmetSearches);
|
|
699
|
+
for (const [date, value] of entries(d.days)) {
|
|
700
|
+
const v = value;
|
|
701
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
702
|
+
dayStats.set(date, {
|
|
309
703
|
calls: num(v.calls),
|
|
310
704
|
errors: num(v.errors),
|
|
311
|
-
|
|
705
|
+
codeLines: num(v.codeLines),
|
|
706
|
+
projects: new Set(strings(v.projects)),
|
|
312
707
|
});
|
|
313
708
|
}
|
|
314
709
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
lastAskedAt: strOrNull(v.lastAskedAt) ?? new Date().toISOString(),
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
}
|
|
710
|
+
pruneDays(today());
|
|
711
|
+
for (const [component, value] of entries(d.components)) {
|
|
712
|
+
const v = value;
|
|
713
|
+
componentStats.set(component, {
|
|
714
|
+
generated: num(v.generated),
|
|
715
|
+
projects: new Set(strings(v.projects)),
|
|
716
|
+
lastGeneratedAt: strOrNull(v.lastGeneratedAt) ?? new Date().toISOString(),
|
|
717
|
+
});
|
|
327
718
|
}
|
|
328
719
|
return true;
|
|
329
720
|
}
|
|
330
|
-
|
|
331
|
-
export function __resetMetrics() {
|
|
721
|
+
function clearState() {
|
|
332
722
|
toolStats.clear();
|
|
333
723
|
clientStats.clear();
|
|
334
724
|
projectStats.clear();
|
|
335
725
|
questionStats.clear();
|
|
726
|
+
unmetSearches.clear();
|
|
727
|
+
dayStats.clear();
|
|
728
|
+
componentStats.clear();
|
|
336
729
|
totalCalls = 0;
|
|
337
730
|
totalErrors = 0;
|
|
731
|
+
totalCodeBlocks = 0;
|
|
732
|
+
totalCodeLines = 0;
|
|
733
|
+
totalReviews = 0;
|
|
734
|
+
totalIssues = 0;
|
|
735
|
+
totalUnmetSearches = 0;
|
|
736
|
+
}
|
|
737
|
+
// Test-only hooks.
|
|
738
|
+
export function __resetMetrics() {
|
|
739
|
+
clearState();
|
|
338
740
|
questionCap = QUESTION_CAP_DEFAULT;
|
|
339
741
|
}
|
|
340
742
|
export function __setQuestionCapForTests(cap) {
|