master
go 1,575 lines 47.2 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package metricsaudit
4
5 import (
6 "fmt"
7 "maps"
8 "slices"
9 "sort"
10 "strings"
11 "time"
12
13 "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
14 )
15
16 func (da *Auditor) PrintReport() {
17 _ = da.flushWriteQueue(2 * time.Second)
18
19 type reportJob struct {
20 id JobID
21 job JobAnalysis
22 }
23
24 da.mu.RLock()
25 jobs := make([]reportJob, 0, len(da.jobs))
26 for id, job := range da.jobs {
27 jobs = append(jobs, reportJob{id: id, job: cloneJobAnalysis(job)})
28 }
29 writeErrorCount := da.writeErrorCount
30 writeErrors := append([]string(nil), da.writeErrors...)
31 da.mu.RUnlock()
32
33 sort.Slice(jobs, func(i, j int) bool {
34 if jobs[i].id.Module == jobs[j].id.Module {
35 return jobs[i].id.Name < jobs[j].id.Name
36 }
37 return jobs[i].id.Module < jobs[j].id.Module
38 })
39
40 for _, entry := range jobs {
41 job := entry.job
42 da.printJobAnalysis(&job)
43 }
44
45 da.printWriteErrorSummary(writeErrorCount, writeErrors)
46 }
47
48 // PrintSummary prints a consolidated summary across all jobs
49 func (da *Auditor) PrintSummary() {
50 // First print the regular report
51 da.PrintReport()
52
53 da.mu.RLock()
54 jobs := make([]JobAnalysis, 0, len(da.jobs))
55 for _, job := range da.jobs {
56 jobs = append(jobs, cloneJobAnalysis(job))
57 }
58 da.mu.RUnlock()
59
60 // Then print the consolidated summary
61 fmt.Println("\n" + strings.Repeat("═", 80))
62 fmt.Println("CONSOLIDATED SUMMARY ACROSS ALL JOBS")
63 fmt.Println(strings.Repeat("═", 80))
64
65 // Collect all contexts across all jobs
66 type contextSummary struct {
67 family string
68 context string
69 title string
70 units string
71 priority int
72 chartType string
73 labelKeys []string
74 dimNames []string
75 instances int
76 jobs map[string]bool
77 }
78
79 contextMap := make(map[string]*contextSummary) // context -> summary
80
81 for i := range jobs {
82 job := &jobs[i]
83 jobLabel := fmt.Sprintf("%s[%s]", job.Module, job.Name)
84 for i := range job.Charts {
85 ca := &job.Charts[i]
86
87 ctx := ca.Chart.Ctx
88 if _, exists := contextMap[ctx]; !exists {
89 // Collect unique label keys
90 labelKeysMap := make(map[string]bool)
91 for _, label := range ca.Chart.Labels {
92 labelKeysMap[label.Key] = true
93 }
94 labelKeys := []string{}
95 for key := range labelKeysMap {
96 labelKeys = append(labelKeys, key)
97 }
98 sort.Strings(labelKeys)
99
100 // Collect unique dimension names
101 dimNamesMap := make(map[string]bool)
102 for _, dim := range ca.Chart.Dims {
103 dimName := dim.Name
104 if dimName == "" {
105 dimName = dim.ID
106 }
107 dimNamesMap[dimName] = true
108 }
109 dimNames := []string{}
110 for name := range dimNamesMap {
111 dimNames = append(dimNames, name)
112 }
113 sort.Strings(dimNames)
114
115 contextMap[ctx] = &contextSummary{
116 family: ca.Chart.Fam,
117 context: ctx,
118 title: ca.Chart.Title,
119 units: ca.Chart.Units,
120 priority: ca.Chart.Priority,
121 chartType: ca.Chart.Type.String(),
122 labelKeys: labelKeys,
123 dimNames: dimNames,
124 instances: 0,
125 jobs: make(map[string]bool),
126 }
127 }
128
129 // Update instance count and job tracking
130 contextMap[ctx].instances++
131 contextMap[ctx].jobs[jobLabel] = true
132
133 // Update label keys and dimension names if needed
134 for _, label := range ca.Chart.Labels {
135 found := slices.Contains(contextMap[ctx].labelKeys, label.Key)
136 if !found {
137 contextMap[ctx].labelKeys = append(contextMap[ctx].labelKeys, label.Key)
138 sort.Strings(contextMap[ctx].labelKeys)
139 }
140 }
141
142 for _, dim := range ca.Chart.Dims {
143 dimName := dim.Name
144 if dimName == "" {
145 dimName = dim.ID
146 }
147 found := slices.Contains(contextMap[ctx].dimNames, dimName)
148 if !found {
149 contextMap[ctx].dimNames = append(contextMap[ctx].dimNames, dimName)
150 sort.Strings(contextMap[ctx].dimNames)
151 }
152 }
153 }
154 }
155
156 // Group contexts by family
157 familyMap := make(map[string][]*contextSummary)
158 for _, cs := range contextMap {
159 family := cs.family
160 if family == "" {
161 family = "(no family)"
162 }
163 familyMap[family] = append(familyMap[family], cs)
164 }
165
166 // Sort families by their minimum priority (priority of their lowest-priority context)
167 type familyPriority struct {
168 family string
169 minPriority int
170 }
171 var familyPriorities []familyPriority
172 for fam, contexts := range familyMap {
173 minPrio := contexts[0].priority
174 for _, ctx := range contexts {
175 if ctx.priority < minPrio {
176 minPrio = ctx.priority
177 }
178 }
179 familyPriorities = append(familyPriorities, familyPriority{family: fam, minPriority: minPrio})
180 }
181 sort.Slice(familyPriorities, func(i, j int) bool {
182 return familyPriorities[i].minPriority < familyPriorities[j].minPriority
183 })
184
185 var families []string
186 for _, fp := range familyPriorities {
187 families = append(families, fp.family)
188 }
189
190 // Print summary with tree structure using colons
191 for i, family := range families {
192 if i == 0 {
193 fmt.Printf("\n┌─ family: %s\n", family)
194 } else {
195 fmt.Printf("\n├─ family: %s\n", family)
196 }
197
198 // Sort contexts by priority
199 contexts := familyMap[family]
200 sort.Slice(contexts, func(i, j int) bool {
201 return contexts[i].priority < contexts[j].priority
202 })
203
204 for j, cs := range contexts {
205 isLastContext := j == len(contexts)-1
206 contextPrefix := "├──"
207 detailPrefix := "│ ├─"
208 lastDetailPrefix := "│ └─"
209
210 if isLastContext {
211 contextPrefix = "└──"
212 detailPrefix = " ├─"
213 lastDetailPrefix = " └─"
214 }
215
216 fmt.Printf("│ %s context: %s, unit: %s, prio: %d, type: %s\n",
217 contextPrefix, cs.context, cs.units, cs.priority, cs.chartType)
218 fmt.Printf("│ %s title: %s\n", detailPrefix, cs.title)
219
220 if len(cs.labelKeys) > 0 {
221 fmt.Printf("│ %s labels: %s\n", detailPrefix, strings.Join(cs.labelKeys, ", "))
222 } else {
223 fmt.Printf("│ %s labels: (none)\n", detailPrefix)
224 }
225
226 fmt.Printf("│ %s dimensions: %s\n", detailPrefix, strings.Join(cs.dimNames, ", "))
227 fmt.Printf("│ %s instances: %d, jobs: %d\n", lastDetailPrefix, cs.instances, len(cs.jobs))
228 }
229 }
230
231 // Add a bottom border for the last family
232 if len(families) > 0 {
233 fmt.Println("└─────────────────────────────────────────────────────────────")
234 }
235 }
236
237 func (da *Auditor) printWriteErrorSummary(count int, samples []string) {
238 if count == 0 {
239 return
240 }
241
242 fmt.Println("\n" + strings.Repeat("═", 80))
243 fmt.Printf("CAPTURE WRITE ERRORS: %d (showing up to %d)\n", count, len(samples))
244 fmt.Println(strings.Repeat("═", 80))
245
246 for _, msg := range samples {
247 fmt.Printf("⚠️ %s\n", msg)
248 }
249 if remaining := count - len(samples); remaining > 0 {
250 fmt.Printf("... %d additional write errors omitted\n", remaining)
251 }
252 }
253
254 type contextInfo struct {
255 family string
256 context string
257 charts []*ChartAnalysis
258 minPriority int
259 }
260
261 func (da *Auditor) printJobAnalysis(job *JobAnalysis) {
262 // First, check for duplicate chart IDs (SEVERE BUG)
263 chartIDCounts := make(map[string]int)
264 for i := range job.Charts {
265 ca := &job.Charts[i]
266 chartIDCounts[ca.Chart.ID]++
267 }
268
269 // Check for contexts appearing in multiple families (SEVERE BUG)
270 contextToFamilies := make(map[string][]string)
271
272 families := make(map[string]map[string]*contextInfo) // family -> context -> info
273 familyMinPriority := make(map[string]int)
274
275 // Track issues for summary
276 contextIssues := make(map[string][]string)
277
278 // Group charts and check for duplicate contexts
279 for i := range job.Charts {
280 ca := &job.Charts[i]
281 family := ca.Chart.Fam
282 if family == "" {
283 family = "(no family)"
284 }
285 ctx := ca.Chart.Ctx
286
287 // Track context to families mapping
288 if _, exists := contextToFamilies[ctx]; !exists {
289 contextToFamilies[ctx] = []string{}
290 }
291 if !contains(contextToFamilies[ctx], family) {
292 contextToFamilies[ctx] = append(contextToFamilies[ctx], family)
293 }
294
295 // Initialize family if needed
296 if _, exists := families[family]; !exists {
297 families[family] = make(map[string]*contextInfo)
298 familyMinPriority[family] = ca.Chart.Priority
299 }
300
301 // Update family minimum priority
302 if ca.Chart.Priority < familyMinPriority[family] {
303 familyMinPriority[family] = ca.Chart.Priority
304 }
305
306 // Initialize context if needed
307 if _, exists := families[family][ctx]; !exists {
308 families[family][ctx] = &contextInfo{
309 family: family,
310 context: ctx,
311 charts: []*ChartAnalysis{},
312 minPriority: ca.Chart.Priority,
313 }
314 }
315
316 // Update context minimum priority
317 if ca.Chart.Priority < families[family][ctx].minPriority {
318 families[family][ctx].minPriority = ca.Chart.Priority
319 }
320
321 families[family][ctx].charts = append(families[family][ctx].charts, ca)
322 }
323
324 // Check for severe bugs - duplicate chart IDs and contexts in multiple families
325 fmt.Printf("\n%s[%s]\n", job.Module, job.Name)
326
327 // Report duplicate chart IDs first (most severe)
328 for chartID, count := range chartIDCounts {
329 if count > 1 {
330 fmt.Printf("🔴 SEVERE BUG: Chart ID '%s' defined %d times - this causes data corruption!\n",
331 chartID, count)
332 contextIssues[chartID] = append(contextIssues[chartID],
333 fmt.Sprintf("SEVERE BUG - chart ID defined %d times (data corruption)", count))
334 }
335 }
336
337 // Report contexts in multiple families
338 for ctx, fams := range contextToFamilies {
339 if len(fams) > 1 {
340 fmt.Printf("🔴 SEVERE BUG: Context '%s' appears in multiple families: %s\n",
341 ctx, strings.Join(fams, ", "))
342 contextIssues[ctx] = append(contextIssues[ctx],
343 fmt.Sprintf("SEVERE BUG - appears in multiple families: %s", strings.Join(fams, ", ")))
344 }
345 }
346
347 // Check for duplicate dimension IDs across ALL charts (SEVERE BUG)
348 allDimIDs := make(map[string][]string) // dimID -> []chartIDs
349 for i := range job.Charts {
350 ca := &job.Charts[i]
351 for _, dim := range ca.Chart.Dims {
352 if _, exists := allDimIDs[dim.ID]; !exists {
353 allDimIDs[dim.ID] = []string{}
354 }
355 allDimIDs[dim.ID] = append(allDimIDs[dim.ID], ca.Chart.ID)
356 }
357 }
358
359 // Report duplicate dimension IDs
360 for dimID, chartIDs := range allDimIDs {
361 if len(chartIDs) > 1 {
362 fmt.Printf("🔴 SEVERE BUG: Dimension ID '%s' is used in %d charts: %s\n",
363 dimID, len(chartIDs), strings.Join(chartIDs, ", "))
364 // Add to issues for each affected context
365 for _, chartID := range chartIDs {
366 // Find the context for this chart
367 for i := range job.Charts {
368 if job.Charts[i].Chart.ID == chartID {
369 ctx := job.Charts[i].Chart.Ctx
370 contextIssues[ctx] = append(contextIssues[ctx],
371 fmt.Sprintf("SEVERE BUG - dimension ID '%s' is shared with charts: %s", dimID, strings.Join(chartIDs, ", ")))
372 break
373 }
374 }
375 }
376 }
377 }
378
379 // Proper excess metrics analysis
380 da.analyzeMetricDimensionMatching(job, allDimIDs, contextIssues)
381
382 // Family structure analysis
383 da.analyzeFamilyStructureForJob(job, contextIssues)
384
385 // Sort families by minimum priority
386 var sortedFamilies []string
387 for fam := range families {
388 sortedFamilies = append(sortedFamilies, fam)
389 }
390 sort.Slice(sortedFamilies, func(i, j int) bool {
391 return familyMinPriority[sortedFamilies[i]] < familyMinPriority[sortedFamilies[j]]
392 })
393
394 // Print analysis for each family
395 for _, family := range sortedFamilies {
396 fmt.Printf("\n├─ family= %s\n", family)
397
398 // Sort contexts by minimum priority
399 var sortedContexts []string
400 for ctx := range families[family] {
401 sortedContexts = append(sortedContexts, ctx)
402 }
403 sort.Slice(sortedContexts, func(i, j int) bool {
404 return families[family][sortedContexts[i]].minPriority <
405 families[family][sortedContexts[j]].minPriority
406 })
407
408 for i, ctx := range sortedContexts {
409 isLast := i == len(sortedContexts)-1
410 ctxInfo := families[family][ctx]
411 issues := da.printContextAnalysis(ctxInfo, isLast)
412 if len(issues) > 0 {
413 contextIssues[ctx] = append(contextIssues[ctx], issues...)
414 }
415 }
416 }
417
418 // Print greppable summary
419 fmt.Println("\n" + strings.Repeat("═", 80))
420 fmt.Println("ISSUE SUMMARY (greppable)")
421 fmt.Println(strings.Repeat("═", 80))
422
423 errorCount := 0
424 warningCount := 0
425 infoCount := 0
426 for ctx, issues := range contextIssues {
427 if len(issues) > 0 {
428 for _, issue := range issues {
429 emoji := "❌"
430 if strings.HasPrefix(issue, "INFO:") {
431 emoji = "ℹ️"
432 infoCount++
433 } else if strings.Contains(issue, "WARNING") {
434 emoji = "🟡"
435 warningCount++
436 } else if strings.Contains(issue, "SEVERE BUG") {
437 emoji = "🔴"
438 errorCount++
439 } else {
440 errorCount++
441 }
442 fmt.Printf("%s IDENTIFIED ISSUES ON %s: %s\n", emoji, ctx, issue)
443 }
444 }
445 }
446
447 issueCount := errorCount // Only count real errors for final status
448
449 // Calculate statistics for the summary independently to avoid interfering with tree logic
450 statsFamilies := make(map[string]bool)
451 statsContexts := make(map[string]bool)
452 statsInstances := 0
453 statsTimeSeries := 0
454 statsCollectedValues := 0
455
456 // Calculate distinct {context}.{dimension} combinations
457 uniqueContextDimensions := make(map[string]bool)
458
459 for i := range job.Charts {
460 ca := &job.Charts[i]
461 statsInstances++
462
463 // Track unique families and contexts for stats
464 family := ca.Chart.Fam
465 if family == "" {
466 family = "(no family)"
467 }
468 statsFamilies[family] = true
469 statsContexts[ca.Chart.Ctx] = true
470
471 // Count dimensions (time-series) and track distinct {context}.{dimension} combinations
472 statsTimeSeries += len(ca.Chart.Dims)
473 for _, dim := range ca.Chart.Dims {
474 statsCollectedValues += len(ca.CollectedValues[dim.ID])
475 // Use dimension name for display, fall back to ID if name is empty
476 dimName := dim.Name
477 if dimName == "" {
478 dimName = dim.ID
479 }
480 // Create unique key as context.dimension
481 uniqueKey := fmt.Sprintf("%s.%s", ca.Chart.Ctx, dimName)
482 uniqueContextDimensions[uniqueKey] = true
483 }
484 }
485
486 // Count total distinct {context}.{dimension} combinations
487 statsDistinctDimensions := len(uniqueContextDimensions)
488
489 // Count unique metrics in mx map
490 uniqueMetricsInMx := len(job.AllSeenMetrics)
491
492 // Generate summary with detailed stats
493 warningText := ""
494 if warningCount > 0 {
495 warningText = fmt.Sprintf(", %d warnings", warningCount)
496 }
497
498 if issueCount == 0 && statsTimeSeries == uniqueMetricsInMx {
499 if warningCount > 0 {
500 fmt.Printf("🟢 NO ISSUES FOUND%s, job %s defines: %d families, %d contexts, %d dimensions, %d instances, %d time-series, collects: %d unique metrics\n",
501 warningText, job.Name, len(statsFamilies), len(statsContexts), statsDistinctDimensions, statsInstances, statsTimeSeries, uniqueMetricsInMx)
502 } else {
503 fmt.Printf("🟢 NO ISSUES FOUND, job %s defines: %d families, %d contexts, %d dimensions, %d instances, %d time-series, collects: %d unique metrics\n",
504 job.Name, len(statsFamilies), len(statsContexts), statsDistinctDimensions, statsInstances, statsTimeSeries, uniqueMetricsInMx)
505 }
506 } else if issueCount == 0 && statsTimeSeries != uniqueMetricsInMx {
507 // Mismatch between time-series and unique metrics even though no specific issues found
508 fmt.Printf("🟡 DIMENSION MISMATCH%s, job %s defines: %d families, %d contexts, %d dimensions, %d instances, %d time-series, collects: %d unique metrics\n",
509 warningText, job.Name, len(statsFamilies), len(statsContexts), statsDistinctDimensions, statsInstances, statsTimeSeries, uniqueMetricsInMx)
510 } else {
511 fmt.Printf("🔴 ISSUES FOUND%s, job %s defines: %d families, %d contexts, %d dimensions, %d instances, %d time-series, collects: %d unique metrics\n",
512 warningText, job.Name, len(statsFamilies), len(statsContexts), statsDistinctDimensions, statsInstances, statsTimeSeries, uniqueMetricsInMx)
513 }
514 }
515
516 func cloneJobAnalysis(src *JobAnalysis) JobAnalysis {
517 if src == nil {
518 return JobAnalysis{}
519 }
520
521 dst := JobAnalysis{
522 Name: src.Name,
523 Module: src.Module,
524 CollectionCount: src.CollectionCount,
525 LastCollection: src.LastCollection,
526 AllSeenMetrics: make(map[string]bool, len(src.AllSeenMetrics)),
527 Charts: make([]ChartAnalysis, len(src.Charts)),
528 }
529 maps.Copy(dst.AllSeenMetrics, src.AllSeenMetrics)
530 for i := range src.Charts {
531 dst.Charts[i] = cloneChartAnalysis(src.Charts[i])
532 }
533
534 return dst
535 }
536
537 func cloneChartAnalysis(src ChartAnalysis) ChartAnalysis {
538 dst := ChartAnalysis{
539 Chart: cloneChart(src.Chart),
540 CollectedValues: make(map[string][]int64, len(src.CollectedValues)),
541 SeenDimensions: make(map[string]bool, len(src.SeenDimensions)),
542 }
543 for id, values := range src.CollectedValues {
544 dst.CollectedValues[id] = append([]int64(nil), values...)
545 }
546 maps.Copy(dst.SeenDimensions, src.SeenDimensions)
547 return dst
548 }
549
550 func cloneChart(src *collectorapi.Chart) *collectorapi.Chart {
551 if src == nil {
552 return nil
553 }
554
555 dst := *src
556 dst.Labels = append([]collectorapi.Label(nil), src.Labels...)
557 dst.Dims = make(collectorapi.Dims, len(src.Dims))
558 for i, dim := range src.Dims {
559 if dim == nil {
560 continue
561 }
562 d := *dim
563 dst.Dims[i] = &d
564 }
565 dst.Vars = make(collectorapi.Vars, len(src.Vars))
566 for i, v := range src.Vars {
567 if v == nil {
568 continue
569 }
570 varCopy := *v
571 dst.Vars[i] = &varCopy
572 }
573
574 return &dst
575 }
576
577 func (da *Auditor) printContextAnalysis(ctxInfo *contextInfo, isLast bool) []string {
578 charts := ctxInfo.charts
579 var issues []string
580
581 // Analyze titles
582 titles := make(map[string]int)
583 for _, ca := range charts {
584 titles[ca.Chart.Title]++
585 }
586
587 // Analyze units
588 units := make(map[string]int)
589 for _, ca := range charts {
590 units[ca.Chart.Units]++
591 }
592
593 // Analyze priorities
594 priorities := make(map[int]int)
595 for _, ca := range charts {
596 priorities[ca.Chart.Priority]++
597 }
598
599 // Analyze label keys
600 labelKeysByChart := make(map[string]map[string]bool) // chartID -> set of keys
601 allLabelKeys := make(map[string]bool)
602 for _, ca := range charts {
603 labelKeysByChart[ca.Chart.ID] = make(map[string]bool)
604 for _, label := range ca.Chart.Labels {
605 labelKeysByChart[ca.Chart.ID][label.Key] = true
606 allLabelKeys[label.Key] = true
607 }
608 }
609
610 // Analyze dimensions
611 dimsByChart := make(map[string]map[string]*collectorapi.Dim) // chartID -> dimID -> dim
612 allDimIDs := make(map[string]bool)
613 for _, ca := range charts {
614 dimsByChart[ca.Chart.ID] = make(map[string]*collectorapi.Dim)
615 for _, dim := range ca.Chart.Dims {
616 dimsByChart[ca.Chart.ID][dim.ID] = dim
617 allDimIDs[dim.ID] = true
618 }
619 }
620
621 // Tree prefixes
622 ctxPrefix := "├─"
623 treePrefix := "│ "
624 if isLast {
625 ctxPrefix = "└─"
626 treePrefix = " "
627 }
628
629 // Print context header
630 fmt.Printf("%s ⚡ context= %s\n", ctxPrefix, ctxInfo.context)
631
632 // Print titles
633 if len(titles) == 1 {
634 for title := range titles {
635 fmt.Printf("%s ├─ title= %s ✅\n", treePrefix, title)
636 }
637 } else {
638 fmt.Printf("%s ├─ title= ❌ INCONSISTENT (%d different titles)\n", treePrefix, len(titles))
639 for title, count := range titles {
640 fmt.Printf("%s │ ├─ %s (in %d charts)\n", treePrefix, title, count)
641 }
642 issues = append(issues, fmt.Sprintf("inconsistent titles (%d different)", len(titles)))
643 }
644
645 // Print units, priority, and chart type on one line
646 unitsStr := ""
647 unitsEmoji := " ✅"
648 if len(units) == 1 {
649 for unit := range units {
650 unitsStr = unit
651 }
652 } else {
653 unitsStr = fmt.Sprintf("INCONSISTENT (%d different)", len(units))
654 unitsEmoji = " ❌"
655 issues = append(issues, fmt.Sprintf("inconsistent units (%d different)", len(units)))
656 }
657
658 priorityStr := ""
659 priorityEmoji := " ✅"
660 if len(priorities) == 1 {
661 for priority := range priorities {
662 priorityStr = fmt.Sprintf("%d", priority)
663 }
664 } else {
665 priorityStr = fmt.Sprintf("%d (INCONSISTENT: %d different)", ctxInfo.minPriority, len(priorities))
666 priorityEmoji = " 🟡"
667 issues = append(issues, fmt.Sprintf("inconsistent priorities (%d different)", len(priorities)))
668 }
669
670 // Collect chart types
671 chartTypes := make(map[string]int)
672 for _, ca := range charts {
673 chartTypes[ca.Chart.Type.String()]++
674 }
675
676 typeStr := ""
677 typeEmoji := " ✅"
678 if len(chartTypes) == 1 {
679 for typ := range chartTypes {
680 typeStr = typ
681 }
682 } else {
683 typeStr = fmt.Sprintf("INCONSISTENT (%d different)", len(chartTypes))
684 typeEmoji = " ❌"
685 issues = append(issues, fmt.Sprintf("inconsistent chart types (%d different)", len(chartTypes)))
686 }
687
688 fmt.Printf("%s ├─ units= %s%s, priority= %s%s, type= %s%s\n",
689 treePrefix, unitsStr, unitsEmoji, priorityStr, priorityEmoji, typeStr, typeEmoji)
690
691 // Print label keys
692 fmt.Printf("%s ├─ label keys= ", treePrefix)
693 if len(allLabelKeys) == 0 {
694 fmt.Printf("(none) ✅\n")
695 } else {
696 var labelKeyList []string
697 for key := range allLabelKeys {
698 labelKeyList = append(labelKeyList, key)
699 }
700 sort.Strings(labelKeyList)
701
702 var labelKeyStatus []string
703 hasInconsistentLabels := false
704 for _, key := range labelKeyList {
705 allHaveIt := true
706 for chartID := range labelKeysByChart {
707 if !labelKeysByChart[chartID][key] {
708 allHaveIt = false
709 hasInconsistentLabels = true
710 break
711 }
712 }
713 if allHaveIt {
714 labelKeyStatus = append(labelKeyStatus, fmt.Sprintf("%s✅", key))
715 } else {
716 labelKeyStatus = append(labelKeyStatus, fmt.Sprintf("%s❌", key))
717 }
718 }
719 fmt.Printf("%s", strings.Join(labelKeyStatus, ", "))
720 if hasInconsistentLabels {
721 fmt.Printf(" 🟡 SOME MISSING")
722 issues = append(issues, "WARNING - inconsistent label keys (natural for heterogeneous instances)")
723 }
724 fmt.Printf("\n")
725 }
726
727 // Collect all dimension names across all charts
728 dimNamesByChart := make(map[string]map[string]string) // chartID -> dimName -> dimID
729 allDimNames := make(map[string]bool)
730
731 for _, ca := range charts {
732 dimNamesByChart[ca.Chart.ID] = make(map[string]string)
733 for _, dim := range ca.Chart.Dims {
734 name := dim.Name
735 if name == "" {
736 name = dim.ID
737 }
738 dimNamesByChart[ca.Chart.ID][name] = dim.ID
739 allDimNames[name] = true
740 }
741 }
742
743 // Print dimensions (names only at context level)
744 fmt.Printf("%s ├─ dimensions=\n", treePrefix)
745 var dimNameList []string
746 for dimName := range allDimNames {
747 dimNameList = append(dimNameList, dimName)
748 }
749 sort.Strings(dimNameList)
750
751 // Check multipliers, dividers, and algorithms consistency across all charts for each dimension
752 dimMultDivInfo := make(map[string]map[string][]int) // dimName -> "mul"/"div" -> []values
753 dimAlgoInfo := make(map[string][]string) // dimName -> []algorithms
754 contextAlgorithms := make(map[string]bool) // track all algorithms used in this context
755
756 for dimName := range allDimNames {
757 dimMultDivInfo[dimName] = map[string][]int{
758 "mul": {},
759 "div": {},
760 }
761 dimAlgoInfo[dimName] = []string{}
762
763 // Collect all multipliers, dividers, and algorithms for this dimension name across charts
764 for _, ca := range charts {
765 for _, dim := range ca.Chart.Dims {
766 name := dim.Name
767 if name == "" {
768 name = dim.ID
769 }
770 if name == dimName {
771 // Treat 0 as 1 (default value)
772 mul := dim.Mul
773 if mul == 0 {
774 mul = 1
775 }
776 div := dim.Div
777 if div == 0 {
778 div = 1
779 }
780 dimMultDivInfo[dimName]["mul"] = append(dimMultDivInfo[dimName]["mul"], mul)
781 dimMultDivInfo[dimName]["div"] = append(dimMultDivInfo[dimName]["div"], div)
782
783 // Collect algorithm
784 algo := dim.Algo.String()
785 dimAlgoInfo[dimName] = append(dimAlgoInfo[dimName], algo)
786 contextAlgorithms[algo] = true
787 }
788 }
789 }
790 }
791
792 // Check for mixed algorithms in the context
793 if len(contextAlgorithms) > 1 {
794 algoList := []string{}
795 for algo := range contextAlgorithms {
796 algoList = append(algoList, algo)
797 }
798 sort.Strings(algoList)
799 issues = append(issues, fmt.Sprintf("mixed dimension algorithms (%s)", strings.Join(algoList, ", ")))
800 }
801
802 // Check for rate units with absolute algorithm
803 if len(units) == 1 && len(contextAlgorithms) == 1 {
804 for unit := range units {
805 for algo := range contextAlgorithms {
806 // Check if unit contains rate indicator (per second, per minute, etc.)
807 if strings.Contains(unit, "/") && algo == "absolute" {
808 issues = append(issues, fmt.Sprintf("WARNING - rate unit '%s' with absolute algorithm (should use incremental)", unit))
809 }
810 }
811 }
812 }
813
814 // Check for generic units that indicate mixed metric types
815 if len(units) == 1 {
816 for unit := range units {
817 lowerUnit := strings.ToLower(unit)
818 // Check for generic counting units
819 if lowerUnit == "value" || lowerUnit == "values" ||
820 lowerUnit == "count" || lowerUnit == "counts" ||
821 lowerUnit == "number" || lowerUnit == "numbers" ||
822 lowerUnit == "amount" || lowerUnit == "amounts" ||
823 lowerUnit == "quantity" || lowerUnit == "quantities" {
824 issues = append(issues, fmt.Sprintf("WARNING - generic unit '%s' suggests mixed metric types (apples and oranges)", unit))
825 }
826 }
827 }
828
829 hasMissingDims := false
830 hasMultDivInconsistency := false
831 for i, dimName := range dimNameList {
832 // Check if all charts have this dimension name
833 allHaveIt := true
834 for chartID := range dimNamesByChart {
835 if _, exists := dimNamesByChart[chartID][dimName]; !exists {
836 allHaveIt = false
837 hasMissingDims = true
838 break
839 }
840 }
841
842 prefix := "├─"
843 if i == len(dimNameList)-1 {
844 prefix = "└─"
845 }
846
847 dimStatus := ""
848 if !allHaveIt {
849 dimStatus = " 🟡 NOT IN ALL CHARTS"
850 }
851
852 // Check multiplier/divider consistency
853 mulValues := dimMultDivInfo[dimName]["mul"]
854 divValues := dimMultDivInfo[dimName]["div"]
855 algoValues := dimAlgoInfo[dimName]
856
857 // Get unique multipliers, dividers, and algorithms
858 uniqueMuls := make(map[int]bool)
859 uniqueDivs := make(map[int]bool)
860 uniqueAlgos := make(map[string]bool)
861 for _, m := range mulValues {
862 uniqueMuls[m] = true
863 }
864 for _, d := range divValues {
865 uniqueDivs[d] = true
866 }
867 for _, a := range algoValues {
868 uniqueAlgos[a] = true
869 }
870
871 // Format multiplier/divider/algorithm info
872 multDivAlgoStr := ""
873 multDivAlgoEmoji := " ✅"
874
875 // Check consistency
876 if len(uniqueMuls) > 1 || len(uniqueDivs) > 1 || len(uniqueAlgos) > 1 {
877 hasMultDivInconsistency = true
878 multDivAlgoEmoji = " ❌"
879 }
880
881 // Format the multiplier/divider/algorithm string - ALWAYS show them
882 if len(uniqueMuls) == 1 && len(uniqueDivs) == 1 && len(uniqueAlgos) == 1 {
883 var mul, div int
884 var algo string
885 for m := range uniqueMuls {
886 mul = m
887 }
888 for d := range uniqueDivs {
889 div = d
890 }
891 for a := range uniqueAlgos {
892 algo = a
893 }
894
895 // Always show multiplier, divider, and algorithm
896 multDivAlgoStr = fmt.Sprintf(" ×%d ÷%d %s", mul, div, algo)
897 } else {
898 // Show all variations if inconsistent
899 parts := []string{}
900
901 if len(uniqueMuls) == 1 {
902 var mul int
903 for m := range uniqueMuls {
904 mul = m
905 }
906 parts = append(parts, fmt.Sprintf("×%d", mul))
907 } else {
908 mulStrs := []string{}
909 for m := range uniqueMuls {
910 mulStrs = append(mulStrs, fmt.Sprintf("%d", m))
911 }
912 parts = append(parts, fmt.Sprintf("×(%s)", strings.Join(mulStrs, ",")))
913 }
914
915 if len(uniqueDivs) == 1 {
916 var div int
917 for d := range uniqueDivs {
918 div = d
919 }
920 parts = append(parts, fmt.Sprintf("÷%d", div))
921 } else {
922 divStrs := []string{}
923 for d := range uniqueDivs {
924 divStrs = append(divStrs, fmt.Sprintf("%d", d))
925 }
926 parts = append(parts, fmt.Sprintf("÷(%s)", strings.Join(divStrs, ",")))
927 }
928
929 if len(uniqueAlgos) == 1 {
930 var algo string
931 for a := range uniqueAlgos {
932 algo = a
933 }
934 parts = append(parts, algo)
935 } else {
936 algoStrs := []string{}
937 for a := range uniqueAlgos {
938 algoStrs = append(algoStrs, a)
939 }
940 sort.Strings(algoStrs)
941 parts = append(parts, fmt.Sprintf("(%s)", strings.Join(algoStrs, ",")))
942 }
943
944 multDivAlgoStr = fmt.Sprintf(" %s", strings.Join(parts, " "))
945 }
946
947 fmt.Printf("%s │ %s %s%s%s%s\n", treePrefix, prefix, dimName, multDivAlgoStr, multDivAlgoEmoji, dimStatus)
948 }
949
950 if hasMissingDims {
951 issues = append(issues, "WARNING - missing dimensions in some charts (natural for heterogeneous instances)")
952 }
953
954 if hasMultDivInconsistency {
955 // Add detailed multiplier/divider inconsistency issues
956 for dimName, info := range dimMultDivInfo {
957 mulValues := info["mul"]
958 divValues := info["div"]
959
960 uniqueMuls := make(map[int]int)
961 uniqueDivs := make(map[int]int)
962 for _, m := range mulValues {
963 uniqueMuls[m]++
964 }
965 for _, d := range divValues {
966 uniqueDivs[d]++
967 }
968
969 if len(uniqueMuls) > 1 {
970 mulStrs := []string{}
971 for m, count := range uniqueMuls {
972 mulStrs = append(mulStrs, fmt.Sprintf("%d (in %d charts)", m, count))
973 }
974 issues = append(issues, fmt.Sprintf("dimension '%s' has inconsistent multipliers: %s", dimName, strings.Join(mulStrs, ", ")))
975 }
976
977 if len(uniqueDivs) > 1 {
978 divStrs := []string{}
979 for d, count := range uniqueDivs {
980 divStrs = append(divStrs, fmt.Sprintf("%d (in %d charts)", d, count))
981 }
982 issues = append(issues, fmt.Sprintf("dimension '%s' has inconsistent dividers: %s", dimName, strings.Join(divStrs, ", ")))
983 }
984 }
985 }
986
987 // Check if any dimensions are missing data across all instances
988 missingDataDetails := []string{}
989 for _, ca := range charts {
990 for _, dim := range ca.Chart.Dims {
991 if !ca.SeenDimensions[dim.ID] || len(ca.CollectedValues[dim.ID]) == 0 {
992 dimName := dim.Name
993 if dimName == "" {
994 dimName = dim.ID
995 }
996 // Show both ID and name for clarity
997 dimInfo := fmt.Sprintf("'%s'", dim.ID)
998 if dim.Name != "" && dim.Name != dim.ID {
999 dimInfo = fmt.Sprintf("'%s' ('%s')", dim.ID, dim.Name)
1000 }
1001 missingDataDetails = append(missingDataDetails, fmt.Sprintf("dimension %s on chart '%s' is not collected", dimInfo, ca.Chart.ID))
1002 }
1003 }
1004 }
1005
1006 // Add all missing data issues
1007 issues = append(issues, missingDataDetails...)
1008
1009 // Print instances
1010 fmt.Printf("%s └─ instances=\n", treePrefix)
1011 for i, ca := range charts {
1012 labelPairs := []string{}
1013 for _, label := range ca.Chart.Labels {
1014 labelPairs = append(labelPairs, fmt.Sprintf("%s=%s", label.Key, label.Value))
1015 }
1016 labelStr := ""
1017 if len(labelPairs) > 0 {
1018 labelStr = fmt.Sprintf(" {%s}", strings.Join(labelPairs, ", "))
1019 }
1020
1021 // Extract name from ID if possible
1022 name := ""
1023 if ca.Chart.OverID != "" {
1024 name = ca.Chart.OverID
1025 }
1026
1027 instPrefix := "├─"
1028 instTreePrefix := "│ "
1029 if i == len(charts)-1 {
1030 instPrefix = "└─"
1031 instTreePrefix = " "
1032 }
1033
1034 fmt.Printf("%s %s %s (%s)%s\n", treePrefix, instPrefix, ca.Chart.ID, name, labelStr)
1035
1036 // Print dimension status for this instance
1037 for _, dim := range ca.Chart.Dims {
1038 dimName := dim.Name
1039 if dimName == "" {
1040 dimName = dim.ID
1041 }
1042
1043 emoji := "❌"
1044 valueStr := ""
1045 if ca.SeenDimensions[dim.ID] && len(ca.CollectedValues[dim.ID]) > 0 {
1046 emoji = "✅"
1047
1048 // Format sample values
1049 values := ca.CollectedValues[dim.ID]
1050 if len(values) > 5 {
1051 // Show first 3 and last 2 values for long series
1052 firstVals := []string{}
1053 for i := range 3 {
1054 firstVals = append(firstVals, fmt.Sprintf("%d", values[i]))
1055 }
1056 lastVals := []string{}
1057 for i := len(values) - 2; i < len(values); i++ {
1058 lastVals = append(lastVals, fmt.Sprintf("%d", values[i]))
1059 }
1060 valueStr = fmt.Sprintf(": [%s, ..., %s] ", strings.Join(firstVals, ", "), strings.Join(lastVals, ", "))
1061 } else {
1062 // Show all values for short series
1063 valStrs := []string{}
1064 for _, v := range values {
1065 valStrs = append(valStrs, fmt.Sprintf("%d", v))
1066 }
1067 valueStr = fmt.Sprintf(": [%s] ", strings.Join(valStrs, ", "))
1068 }
1069 }
1070
1071 // Format multiplier/divider and algorithm for this specific dimension
1072 mul := dim.Mul
1073 div := dim.Div
1074 // Treat 0 as 1 (what the framework does)
1075 if mul == 0 {
1076 mul = 1
1077 }
1078 if div == 0 {
1079 div = 1
1080 }
1081
1082 // Get algorithm
1083 algo := string(dim.Algo)
1084 if algo == "" {
1085 algo = "absolute"
1086 }
1087
1088 // Always show multiplier, divider and algorithm
1089 multDivAlgoStr := fmt.Sprintf(" ×%d ÷%d %s", mul, div, algo)
1090
1091 fmt.Printf("%s %s %s %s%s%s %s\n", treePrefix, instTreePrefix, emoji, dimName, multDivAlgoStr, valueStr, dim.ID)
1092 }
1093
1094 }
1095
1096 return issues
1097 }
1098
1099 func contains(slice []string, item string) bool {
1100 return slices.Contains(slice, item)
1101 }
1102
1103 // analyzeMetricDimensionMatching performs comprehensive analysis of dimension/metric matching
1104 func (da *Auditor) analyzeMetricDimensionMatching(job *JobAnalysis, allDimIDs map[string][]string, contextIssues map[string][]string) {
1105 // 1. Find duplicate dimension IDs across charts (already done above but let's be explicit)
1106 duplicateDimensions := []string{}
1107 for dimID, chartIDs := range allDimIDs {
1108 if len(chartIDs) > 1 {
1109 duplicateDimensions = append(duplicateDimensions, dimID)
1110 // Find affected contexts
1111 affectedContexts := make(map[string]bool)
1112 for _, chartID := range chartIDs {
1113 for i := range job.Charts {
1114 if job.Charts[i].Chart.ID == chartID {
1115 affectedContexts[job.Charts[i].Chart.Ctx] = true
1116 break
1117 }
1118 }
1119 }
1120 for ctx := range affectedContexts {
1121 contextIssues[ctx] = append(contextIssues[ctx],
1122 fmt.Sprintf("SEVERE BUG - dimension '%s' is used in multiple charts: %s", dimID, strings.Join(chartIDs, ", ")))
1123 }
1124 }
1125 }
1126
1127 // 2. Get unique dimension IDs from charts
1128 chartDimensions := make(map[string]bool)
1129 for dimID := range allDimIDs {
1130 chartDimensions[dimID] = true
1131 }
1132
1133 // 3. Get unique dimension IDs from values map (AllSeenMetrics)
1134 valuesDimensions := make(map[string]bool)
1135 for metricID := range job.AllSeenMetrics {
1136 valuesDimensions[metricID] = true
1137 }
1138
1139 // 4. Find dimensions in charts but not in values (missing data)
1140 missingValues := []string{}
1141 for dimID := range chartDimensions {
1142 if !valuesDimensions[dimID] {
1143 missingValues = append(missingValues, dimID)
1144 }
1145 }
1146
1147 // 5. Find dimensions in values but not in charts (excess metrics)
1148 excessMetrics := []string{}
1149 for metricID := range valuesDimensions {
1150 if !chartDimensions[metricID] {
1151 excessMetrics = append(excessMetrics, metricID)
1152 }
1153 }
1154
1155 // Group missing values by context for reporting
1156 if len(missingValues) > 0 {
1157 contextMissingValues := make(map[string][]string)
1158 for _, dimID := range missingValues {
1159 // Find which context this dimension belongs to
1160 for i := range job.Charts {
1161 ca := &job.Charts[i]
1162 for _, dim := range ca.Chart.Dims {
1163 if dim.ID == dimID {
1164 contextMissingValues[ca.Chart.Ctx] = append(contextMissingValues[ca.Chart.Ctx], dimID)
1165 break
1166 }
1167 }
1168 }
1169 }
1170
1171 for ctx, dims := range contextMissingValues {
1172 sort.Strings(dims)
1173 contextIssues[ctx] = append(contextIssues[ctx],
1174 fmt.Sprintf("dimensions %s in charts do not have collected values", strings.Join(dims, ", ")))
1175 }
1176 }
1177
1178 // Report excess metrics
1179 if len(excessMetrics) > 0 {
1180 sort.Strings(excessMetrics)
1181 contextIssues["_general"] = append(contextIssues["_general"],
1182 fmt.Sprintf("dimensions %s in the values map, do not exist in charts", strings.Join(excessMetrics, ", ")))
1183 }
1184
1185 // Print success messages with counts if no issues
1186 if len(duplicateDimensions) == 0 {
1187 fmt.Printf("✅ DIMENSION UNIQUENESS: All %d dimensions have unique IDs across charts\n", len(chartDimensions))
1188 }
1189
1190 if len(missingValues) == 0 && len(excessMetrics) == 0 {
1191 fmt.Printf("✅ DIMENSION/VALUES MATCHING: %d chart dimensions perfectly match %d collected values\n",
1192 len(chartDimensions), len(valuesDimensions))
1193 } else {
1194 if len(missingValues) > 0 {
1195 fmt.Printf("❌ MISSING VALUES: %d chart dimensions have no collected values\n", len(missingValues))
1196 }
1197 if len(excessMetrics) > 0 {
1198 fmt.Printf("❌ EXCESS VALUES: %d collected values have no corresponding chart dimensions\n", len(excessMetrics))
1199 }
1200 }
1201 }
1202
1203 // gcd calculates the greatest common divisor
1204 func gcd(a, b int) int {
1205 for b != 0 {
1206 a, b = b, a%b
1207 }
1208 return a
1209 }
1210
1211 // analyzeFamilyStructureForJob performs family-level structural analysis for a single job
1212 func (da *Auditor) analyzeFamilyStructureForJob(job *JobAnalysis, contextIssues map[string][]string) {
1213 // Get all charts from this job
1214 allCharts := []*ChartAnalysis{}
1215 for i := range job.Charts {
1216 allCharts = append(allCharts, &job.Charts[i])
1217 }
1218
1219 // Group charts by family
1220 type familyInfo struct {
1221 contexts map[string][]*ChartAnalysis // context -> charts
1222 labelPairs map[string]int // "key=value" -> count
1223 hasSubfamilies bool
1224 subfamilies map[string]bool
1225 }
1226
1227 families := make(map[string]*familyInfo) // family -> info
1228 topLevelFamilies := make(map[string]bool)
1229
1230 for _, ca := range allCharts {
1231 family := ca.Chart.Fam
1232 if family == "" {
1233 family = "(no family)"
1234 }
1235
1236 // We'll check family depth later after all families are processed
1237
1238 // Extract top-level family
1239 topLevel := family
1240 if before, _, ok := strings.Cut(family, "/"); ok {
1241 topLevel = before
1242 }
1243 topLevelFamilies[topLevel] = true
1244
1245 // Initialize family info
1246 if _, exists := families[family]; !exists {
1247 families[family] = &familyInfo{
1248 contexts: make(map[string][]*ChartAnalysis),
1249 labelPairs: make(map[string]int),
1250 subfamilies: make(map[string]bool),
1251 }
1252 }
1253
1254 // Track contexts
1255 ctx := ca.Chart.Ctx
1256 families[family].contexts[ctx] = append(families[family].contexts[ctx], ca)
1257
1258 // Track label pairs
1259 for _, label := range ca.Chart.Labels {
1260 pair := fmt.Sprintf("%s=%s", label.Key, label.Value)
1261 families[family].labelPairs[pair]++
1262 }
1263
1264 // Check for subfamilies
1265 if strings.Contains(family, "/") {
1266 parentFamily := family[:strings.Index(family, "/")]
1267 if _, exists := families[parentFamily]; !exists {
1268 families[parentFamily] = &familyInfo{
1269 contexts: make(map[string][]*ChartAnalysis),
1270 labelPairs: make(map[string]int),
1271 subfamilies: make(map[string]bool),
1272 }
1273 }
1274 families[parentFamily].hasSubfamilies = true
1275 families[parentFamily].subfamilies[family] = true
1276 }
1277 }
1278
1279 // Rule 0: Check family depth (deferred until all families are processed)
1280 for family, info := range families {
1281 slashCount := strings.Count(family, "/")
1282 if slashCount > 2 {
1283 // Add to the first context in this family
1284 for ctx := range info.contexts {
1285 contextIssues[ctx] = append(contextIssues[ctx],
1286 fmt.Sprintf("family '%s' exceeds maximum depth of 3 (has %d slashes); possible cause: over-nested hierarchy; possible fix: flatten to maximum 3 levels", family, slashCount))
1287 break
1288 }
1289 }
1290 }
1291
1292 // Rule 1: Check label consistency within families
1293 for family, info := range families {
1294 if len(info.contexts) < 2 {
1295 continue // Skip single-context families
1296 }
1297
1298 // Calculate total charts in this family
1299 totalCharts := 0
1300 for _, charts := range info.contexts {
1301 totalCharts += len(charts)
1302 }
1303
1304 // Find inconsistent label pairs
1305 inconsistentPairs := []string{}
1306
1307 // The base unit is the number of contexts in the family
1308 // Each label key-value pair should appear in multiples of this
1309 baseUnit := len(info.contexts)
1310
1311 // Check that each label pair count is a multiple of the base unit
1312 for pair, actualCount := range info.labelPairs {
1313 if baseUnit > 0 && actualCount%baseUnit != 0 {
1314 inconsistentPairs = append(inconsistentPairs, fmt.Sprintf("'%s': %d", pair, actualCount))
1315 }
1316 }
1317
1318 if len(inconsistentPairs) > 0 {
1319 // Limit to first 10 pairs for readability
1320 displayPairs := inconsistentPairs
1321 if len(inconsistentPairs) > 10 {
1322 displayPairs = inconsistentPairs[:10]
1323 displayPairs = append(displayPairs, fmt.Sprintf("... and %d more", len(inconsistentPairs)-10))
1324 }
1325
1326 // Add to all contexts in this family
1327 for ctx := range info.contexts {
1328 contextIssues[ctx] = append(contextIssues[ctx],
1329 fmt.Sprintf("INFO: family '%s' has inconsistent label pairs. Each key-value pair should appear in multiples of %d (the number of contexts), but got: %s; possible cause: not all instances have the same labels; possible fix: ensure all charts in the family have consistent labels or split into separate families",
1330 family, baseUnit, strings.Join(displayPairs, ", ")))
1331 }
1332 }
1333 }
1334
1335 // Rule 2: Check same number of instances per context in a family
1336 for family, info := range families {
1337 if len(info.contexts) > 1 {
1338 instanceCounts := make(map[int][]string)
1339 for ctx, charts := range info.contexts {
1340 count := len(charts)
1341 instanceCounts[count] = append(instanceCounts[count], ctx)
1342 }
1343
1344 if len(instanceCounts) > 1 {
1345 details := []string{}
1346 for count, contexts := range instanceCounts {
1347 details = append(details, fmt.Sprintf("%d instances: %s", count, strings.Join(contexts, ", ")))
1348 }
1349 // Add to all contexts in this family
1350 for ctx := range info.contexts {
1351 contextIssues[ctx] = append(contextIssues[ctx],
1352 fmt.Sprintf("INFO: family '%s' has different number of instances per context (%s); possible cause: monitoring different types of objects or missing data collection; possible fix: split into separate families or fix data collection",
1353 family, strings.Join(details, "; ")))
1354 }
1355 }
1356 }
1357 }
1358
1359 // Rule 3: Check snake_case contexts
1360 for _, ca := range allCharts {
1361 ctx := ca.Chart.Ctx
1362 if !isSnakeCase(ctx) {
1363 contextIssues[ctx] = append(contextIssues[ctx],
1364 fmt.Sprintf("context '%s' is not in snake_case format; possible cause: incorrect naming convention; possible fix: use lowercase with underscores (e.g., 'my_metric_name')", ctx))
1365 }
1366 }
1367
1368 // Rule 4: Check families with >15 contexts
1369 for family, info := range families {
1370 if len(info.contexts) > 15 {
1371 // Add to all contexts in this family
1372 for ctx := range info.contexts {
1373 contextIssues[ctx] = append(contextIssues[ctx],
1374 fmt.Sprintf("family '%s' has %d contexts (exceeds recommended 15); possible cause: too many metric types in one family; possible fix: split into subfamilies or make some contexts into instances with labels",
1375 family, len(info.contexts)))
1376 }
1377 }
1378 }
1379
1380 // Rule 5: Check generic family names
1381 genericFamilies := map[string]bool{
1382 "other": true,
1383 "infrastructure": true,
1384 "runtime": true,
1385 }
1386
1387 for family := range families {
1388 // Check only the base family name (before /)
1389 baseName := family
1390 if before, _, ok := strings.Cut(family, "/"); ok {
1391 baseName = before
1392 }
1393
1394 if genericFamilies[strings.ToLower(baseName)] {
1395 // Add to all contexts in this family
1396 for ctx := range families[family].contexts {
1397 contextIssues[ctx] = append(contextIssues[ctx],
1398 fmt.Sprintf("family '%s' uses generic name '%s'; possible cause: unclear categorization; possible fix: use specific names like 'database', 'webserver', 'messaging', etc.",
1399 family, baseName))
1400 }
1401 }
1402 }
1403
1404 // Rule 6: Check families with both direct contexts and subfamilies
1405 for family, info := range families {
1406 if len(info.contexts) > 0 && info.hasSubfamilies {
1407 // This is a parent family with both direct contexts and subfamilies
1408 if !strings.Contains(family, "/") {
1409 // Add to all contexts in this family
1410 for ctx := range info.contexts {
1411 contextIssues[ctx] = append(contextIssues[ctx],
1412 fmt.Sprintf("family '%s' has both direct contexts and subfamilies; possible cause: mixed hierarchy; possible fix: move direct contexts to '%s/overview' or similar",
1413 family, family))
1414 }
1415 }
1416 }
1417 }
1418
1419 // Rule 7: Check top-level family count
1420 if len(topLevelFamilies) > 15 {
1421 familyList := []string{}
1422 for f := range topLevelFamilies {
1423 familyList = append(familyList, f)
1424 }
1425 sort.Strings(familyList)
1426
1427 // Add to general issues (first context found)
1428 for _, ca := range allCharts {
1429 contextIssues[ca.Chart.Ctx] = append(contextIssues[ca.Chart.Ctx],
1430 fmt.Sprintf("found %d top-level families (exceeds recommended 15): %s; possible cause: too many categories; possible fix: consolidate related families or use subfamilies",
1431 len(topLevelFamilies), strings.Join(familyList, ", ")))
1432 break // Only add once
1433 }
1434 }
1435
1436 // Rule 8: Check subfamily counts
1437 for family, info := range families {
1438 if !strings.Contains(family, "/") && info.hasSubfamilies {
1439 // This is a parent family, check its subfamilies
1440 subfamilyCount := len(info.subfamilies)
1441
1442 // Check for singleton subfamily without siblings
1443 if subfamilyCount == 1 {
1444 // Get the single subfamily name
1445 var singleSubfamily string
1446 for sf := range info.subfamilies {
1447 singleSubfamily = sf
1448 }
1449 // Add to contexts in the parent family (if any) or the subfamily
1450 if len(info.contexts) > 0 {
1451 for ctx := range info.contexts {
1452 contextIssues[ctx] = append(contextIssues[ctx],
1453 fmt.Sprintf("family '%s' has only one subfamily '%s'; possible cause: incomplete hierarchy; possible fix: either add more subfamilies or flatten the structure",
1454 family, singleSubfamily))
1455 }
1456 } else {
1457 // Add to contexts in the single subfamily
1458 if subfamilyInfo, exists := families[singleSubfamily]; exists {
1459 for ctx := range subfamilyInfo.contexts {
1460 contextIssues[ctx] = append(contextIssues[ctx],
1461 fmt.Sprintf("family '%s' has only one subfamily '%s'; possible cause: incomplete hierarchy; possible fix: either add more subfamilies or flatten the structure",
1462 family, singleSubfamily))
1463 }
1464 }
1465 }
1466 }
1467
1468 // Check for too many subfamilies
1469 if subfamilyCount > 8 {
1470 // Add to contexts in the parent family (if any) or all subfamily contexts
1471 if len(info.contexts) > 0 {
1472 for ctx := range info.contexts {
1473 contextIssues[ctx] = append(contextIssues[ctx],
1474 fmt.Sprintf("family '%s' has %d subfamilies (exceeds recommended 8); possible cause: too many subcategories; possible fix: consolidate related subfamilies or create a deeper hierarchy",
1475 family, subfamilyCount))
1476 }
1477 } else {
1478 // Add to one context from each subfamily
1479 for subfamily := range info.subfamilies {
1480 if subfamilyInfo, exists := families[subfamily]; exists {
1481 for ctx := range subfamilyInfo.contexts {
1482 contextIssues[ctx] = append(contextIssues[ctx],
1483 fmt.Sprintf("family '%s' has %d subfamilies (exceeds recommended 8); possible cause: too many subcategories; possible fix: consolidate related subfamilies or create a deeper hierarchy",
1484 family, subfamilyCount))
1485 break // Only add to one context per subfamily
1486 }
1487 }
1488 }
1489 }
1490 }
1491 }
1492 }
1493 }
1494
1495 // isSnakeCase checks if a string is in snake_case format
1496 func isSnakeCase(s string) bool {
1497 // Should be lowercase with underscores, dots allowed for contexts
1498 for _, ch := range s {
1499 if !((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '.') {
1500 return false
1501 }
1502 }
1503 return true
1504 }
1505
1506 // PrintDebugInfo prints additional debug information
1507 func (da *Auditor) PrintDebugInfo() {
1508 da.mu.RLock()
1509 defer da.mu.RUnlock()
1510
1511 fmt.Println("\n\nDEBUG INFORMATION:")
1512 fmt.Println(strings.Repeat("-", 80))
1513
1514 type debugEntry struct {
1515 id JobID
1516 job *JobAnalysis
1517 }
1518
1519 entries := make([]debugEntry, 0, len(da.jobs))
1520 for id, job := range da.jobs {
1521 entries = append(entries, debugEntry{id: id, job: job})
1522 }
1523 sort.Slice(entries, func(i, j int) bool {
1524 if entries[i].id.Module == entries[j].id.Module {
1525 return entries[i].id.Name < entries[j].id.Name
1526 }
1527 return entries[i].id.Module < entries[j].id.Module
1528 })
1529
1530 for _, entry := range entries {
1531 job := entry.job
1532 fmt.Printf("\n[%s][%s] Chart Structure:\n", entry.id.Module, entry.id.Name)
1533
1534 for _, ca := range job.Charts {
1535 fmt.Printf("\nChart ID: %s\n", ca.Chart.ID)
1536 fmt.Printf(" Context: %s\n", ca.Chart.Ctx)
1537 fmt.Printf(" Title: %s\n", ca.Chart.Title)
1538 fmt.Printf(" Units: %s\n", ca.Chart.Units)
1539 fmt.Printf(" Family: %s\n", ca.Chart.Fam)
1540 fmt.Printf(" Type: %s\n", ca.Chart.Type)
1541 fmt.Printf(" Priority: %d\n", ca.Chart.Priority)
1542
1543 if len(ca.Chart.Labels) > 0 {
1544 fmt.Printf(" Labels:\n")
1545 for _, label := range ca.Chart.Labels {
1546 fmt.Printf(" %s: %s\n", label.Key, label.Value)
1547 }
1548 }
1549
1550 fmt.Printf(" Dimensions:\n")
1551 for _, dim := range ca.Chart.Dims {
1552 status := "INACTIVE"
1553 valueCount := 0
1554 if ca.SeenDimensions[dim.ID] {
1555 status = "ACTIVE"
1556 valueCount = len(ca.CollectedValues[dim.ID])
1557 }
1558
1559 fmt.Printf(" %s (%s) - %s [%d values collected]\n",
1560 dim.ID, dim.Name, status, valueCount)
1561
1562 // Show sample values if collected
1563 if valueCount > 0 {
1564 samples := ca.CollectedValues[dim.ID]
1565 if valueCount > 5 {
1566 fmt.Printf(" Sample values: %v ... %v\n",
1567 samples[:3], samples[valueCount-2:])
1568 } else {
1569 fmt.Printf(" Values: %v\n", samples)
1570 }
1571 }
1572 }
1573 }
1574 }
1575 }