@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/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
- export function recordToolCall(name, durationMs, ok, client, project) {
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 = { calls: 0, errors: 0, totalMs: 0, maxMs: 0, lastUsedAt: null };
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 (project) {
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 = { calls: 0, errors: 0, lastSeenAt: null };
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 evictColdestQuestion() {
336
+ function evictColdest(map) {
126
337
  let coldestKey = null;
127
338
  let coldest = null;
128
- for (const [key, q] of questionStats) {
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
- questionStats.delete(coldestKey);
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
- export function recordQuestion(tool, args) {
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
- const existing = questionStats.get(key);
159
- if (existing) {
160
- existing.count += 1;
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
- if (questionStats.size >= questionCap) {
165
- evictColdestQuestion();
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 topQuestions = [...questionStats.values()]
198
- .sort((a, b) => b.count - a.count)
199
- .slice(0, TOP_QUESTIONS_LIMIT)
200
- .map(q => ({ ...q }));
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
- toolStats.clear();
278
- clientStats.clear();
279
- projectStats.clear();
280
- questionStats.clear();
653
+ clearState();
281
654
  totalCalls = num(d.totalCalls);
282
655
  totalErrors = num(d.totalErrors);
283
- for (const e of Array.isArray(d.tools) ? d.tools : []) {
284
- if (Array.isArray(e) && typeof e[0] === 'string' && e[1]) {
285
- const v = e[1];
286
- toolStats.set(e[0], {
287
- calls: num(v.calls),
288
- errors: num(v.errors),
289
- totalMs: num(v.totalMs),
290
- maxMs: num(v.maxMs),
291
- lastUsedAt: strOrNull(v.lastUsedAt),
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 e of Array.isArray(d.clients) ? d.clients : []) {
296
- if (Array.isArray(e) && typeof e[0] === 'string' && e[1]) {
297
- const v = e[1];
298
- clientStats.set(e[0], {
299
- calls: num(v.calls),
300
- errors: num(v.errors),
301
- lastSeenAt: strOrNull(v.lastSeenAt),
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
- for (const e of Array.isArray(d.projects) ? d.projects : []) {
306
- if (Array.isArray(e) && typeof e[0] === 'string' && e[1]) {
307
- const v = e[1];
308
- projectStats.set(e[0], {
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
- lastSeenAt: strOrNull(v.lastSeenAt),
705
+ codeLines: num(v.codeLines),
706
+ projects: new Set(strings(v.projects)),
312
707
  });
313
708
  }
314
709
  }
315
- for (const e of Array.isArray(d.questions) ? d.questions : []) {
316
- if (Array.isArray(e) && typeof e[0] === 'string' && e[1]) {
317
- const v = e[1];
318
- if (typeof v.text === 'string' && typeof v.tool === 'string') {
319
- questionStats.set(e[0], {
320
- text: v.text,
321
- tool: v.tool,
322
- count: num(v.count),
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
- // Test-only hooks.
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) {