@cryptotaxi247 / netdata-1 / commits / 0f8600101

refactor(go.d): tighten agent dyncfg flow and type safety (#21808)

Ilya Mashchenko committed Feb 25, 2026 at 07:41 UTC 0f8600101c3c63864c8e6baa507b7766c96a7ab7
38 files changed +2758 -2536
src/go/cmd/godplugin/main.go
+13 -12
@@ -164,18 +164,19 @@ func runFunctionCLI(opts *cli.Option) int {
164 ctx, cancel := context.WithCancel(context.Background())
165 defer cancel()
166
167 - jobMgr := jobmgr.New()
168 - // Force-enable configs in function CLI runs (non-TTY by default).
169 - jobMgr.PluginName = "nodyncfg"
170 - jobMgr.Out = io.Discard
171 - jobMgr.VarLibDir = pluginconfig.VarLibDir()
172 - jobMgr.Modules = collectorapi.Registry{moduleName: creator}
173 - jobMgr.ConfigDefaults = reg
174 - jobMgr.FnReg = functions.NewManager()
175 - jobMgr.FunctionJSONWriter = func(payload []byte, _ int) {
176 - _, _ = os.Stdout.Write(payload)
177 - _, _ = os.Stdout.Write([]byte("\n"))
178 - }
167 + jobMgr := jobmgr.New(jobmgr.Config{
168 + // Force-enable configs in function CLI runs (non-TTY by default).
169 + PluginName: "nodyncfg",
170 + Out: io.Discard,
171 + VarLibDir: pluginconfig.VarLibDir(),
172 + Modules: collectorapi.Registry{moduleName: creator},
173 + ConfigDefaults: reg,
174 + FnReg: functions.NewManager(),
175 + FunctionJSONWriter: func(payload []byte, _ int) {
176 + _, _ = os.Stdout.Write(payload)
177 + _, _ = os.Stdout.Write([]byte("\n"))
178 + },
179 + })
180 jobMgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(io.Discard)))
181
182 in := make(chan []*confgroup.Group, 1)
src/go/plugin/agent/agent.go
+25 -28
@@ -12,12 +12,12 @@ import (
12 "syscall"
13 "time"
14
15 - "github.com/mattn/go-isatty"
15 "github.com/netdata/netdata/go/plugins/logger"
16 "github.com/netdata/netdata/go/plugins/pkg/multipath"
17 "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
18 "github.com/netdata/netdata/go/plugins/pkg/safewriter"
19 "github.com/netdata/netdata/go/plugins/plugin/agent/discovery"
20 + "github.com/netdata/netdata/go/plugins/plugin/agent/internal/terminal"
21 "github.com/netdata/netdata/go/plugins/plugin/agent/jobmgr"
22 "github.com/netdata/netdata/go/plugins/plugin/agent/runtimemgr"
23 "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
@@ -25,8 +25,6 @@ import (
25 "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
26 )
27
28 -var isTerminal = isatty.IsTerminal(os.Stdout.Fd())
29 -
28 // Config is an Agent configuration.
29 type Config struct {
30 Name string
@@ -217,7 +215,7 @@ func (a *Agent) run(ctx context.Context) {
215
216 if !cfg.Enabled {
217 a.Info("plugin is disabled in the configuration file, exiting...")
220 - if isTerminal {
218 + if terminal.IsTerminal() {
219 os.Exit(0)
220 }
221 a.api.DISABLE()
@@ -227,7 +225,7 @@ func (a *Agent) run(ctx context.Context) {
225 enabledModules := a.loadEnabledModules(cfg)
226 if len(enabledModules) == 0 {
227 a.Info("no modules to run")
230 - if isTerminal {
228 + if terminal.IsTerminal() {
229 os.Exit(0)
230 }
231 a.api.DISABLE()
@@ -241,39 +239,38 @@ func (a *Agent) run(ctx context.Context) {
239 discMgr, err := discovery.NewManager(discCfg)
240 if err != nil {
241 a.Error(err)
244 - if isTerminal {
242 + if terminal.IsTerminal() {
243 os.Exit(0)
244 }
245 return
246 }
247
250 - jobMgr := jobmgr.New()
251 - jobMgr.PluginName = a.Name
252 - jobMgr.Out = a.Out
253 - jobMgr.VarLibDir = a.VarLibDir
254 - jobMgr.Modules = enabledModules
255 - if a.RunModule != "" && a.RunModule != "all" {
256 - jobMgr.RunJob = a.RunJob
257 - }
258 - jobMgr.ConfigDefaults = discCfg.Registry
259 - jobMgr.FnReg = fnMgr
260 -
248 runtimeSvc := runtimemgr.New(a.Logger.With(slog.String("component", "runtime metrics service")))
249 runtimeSvc.Start(a.Name, a.Out)
250 defer runtimeSvc.Stop()
264 - jobMgr.RuntimeService = runtimeSvc
251
266 - // Store reference for dump mode and enable dump mode if configured
267 - a.mgr = jobMgr
268 - if a.dumpMode > 0 {
269 - jobMgr.DumpMode = true
270 - jobMgr.DumpAnalyzer = a.dumpAnalyzer
252 + var runJob []string
253 + if a.RunModule != "" && a.RunModule != "all" {
254 + runJob = a.RunJob
255 }
272 - jobMgr.DumpDataDir = a.dumpDataDir
256
274 - if reg := a.setupVnodeRegistry(); len(reg) > 0 {
275 - jobMgr.Vnodes = reg
276 - }
257 + jobMgr := jobmgr.New(jobmgr.Config{
258 + PluginName: a.Name,
259 + Out: a.Out,
260 + Modules: enabledModules,
261 + RunJob: runJob,
262 + ConfigDefaults: discCfg.Registry,
263 + VarLibDir: a.VarLibDir,
264 + FnReg: fnMgr,
265 + Vnodes: a.setupVnodeRegistry(),
266 + DumpMode: a.dumpMode > 0,
267 + DumpAnalyzer: a.dumpAnalyzer,
268 + DumpDataDir: a.dumpDataDir,
269 + RuntimeService: runtimeSvc,
270 + })
271 +
272 + // Store reference for dump mode and enable dump mode if configured
273 + a.mgr = jobMgr
274
275 in := make(chan []*confgroup.Group)
276 var wg sync.WaitGroup
@@ -292,7 +289,7 @@ func (a *Agent) run(ctx context.Context) {
289 }
290
291 func (a *Agent) keepAlive() {
295 - if isTerminal {
292 + if terminal.IsTerminal() {
293 return
294 }
295
src/go/plugin/agent/discovery/file/read_test.go
+13 -12
@@ -12,24 +12,25 @@ import (
12 "github.com/stretchr/testify/assert"
13 )
14
15 -func TestReader_String(t *testing.T) {
16 - assert.NotEmpty(t, NewReader(confgroup.Registry{}, nil))
17 -}
18 -
19 -func TestNewReader(t *testing.T) {
15 +func TestReader_New(t *testing.T) {
16 tests := map[string]struct {
21 - reg confgroup.Registry
22 - paths []string
17 + run func(t *testing.T)
18 }{
24 - "empty inputs": {
25 - reg: confgroup.Registry{},
26 - paths: []string{},
19 + "string is not empty": {
20 + run: func(t *testing.T) {
21 + assert.NotEmpty(t, NewReader(confgroup.Registry{}, nil))
22 + },
23 + },
24 + "empty inputs create reader": {
25 + run: func(t *testing.T) {
26 + assert.NotNil(t, NewReader(confgroup.Registry{}, []string{}))
27 + },
28 },
29 }
30
30 - for name, test := range tests {
31 + for name, tc := range tests {
32 t.Run(name, func(t *testing.T) {
32 - assert.NotNil(t, NewReader(test.reg, test.paths))
33 + tc.run(t)
34 })
35 }
36 }
src/go/plugin/agent/discovery/file/watch_test.go
+13 -12
@@ -13,24 +13,25 @@ import (
13 "github.com/stretchr/testify/assert"
14 )
15
16 -func TestWatcher_String(t *testing.T) {
17 - assert.NotEmpty(t, NewWatcher(confgroup.Registry{}, nil))
18 -}
19 -
20 -func TestNewWatcher(t *testing.T) {
16 +func TestWatcher_New(t *testing.T) {
17 tests := map[string]struct {
22 - reg confgroup.Registry
23 - paths []string
18 + run func(t *testing.T)
19 }{
25 - "empty inputs": {
26 - reg: confgroup.Registry{},
27 - paths: []string{},
20 + "string is not empty": {
21 + run: func(t *testing.T) {
22 + assert.NotEmpty(t, NewWatcher(confgroup.Registry{}, nil))
23 + },
24 + },
25 + "empty inputs create watcher": {
26 + run: func(t *testing.T) {
27 + assert.NotNil(t, NewWatcher(confgroup.Registry{}, []string{}))
28 + },
29 },
30 }
31
31 - for name, test := range tests {
32 + for name, tc := range tests {
33 t.Run(name, func(t *testing.T) {
33 - assert.NotNil(t, NewWatcher(test.reg, test.paths))
34 + tc.run(t)
35 })
36 }
37 }
src/go/plugin/agent/discovery/sd/dyncfg.go
+4 -19
@@ -117,12 +117,6 @@ func (cb *sdCallbacks) ConfigID(cfg sdConfig) string {
117 return cb.sd.dyncfgJobID(cfg.DiscovererType(), cfg.Name())
118 }
119
120 -// dyncfgConfigHandler wraps dyncfgConfig to convert functions.Function to dyncfg.Function.
121 -// This is needed because functions.Registry expects func(functions.Function).
122 -func (d *ServiceDiscovery) dyncfgConfigHandler(fn functions.Function) {
123 - d.dyncfgConfig(dyncfg.NewFunction(fn))
124 -}
125 -
120 // dyncfgConfig is the handler for dyncfg config commands.
121 // Read-only commands (schema, get, userconfig) are executed directly.
122 // State-changing commands are queued for serial execution.
@@ -160,16 +154,7 @@ func (d *ServiceDiscovery) dyncfgConfig(fn dyncfg.Function) {
154
155 // dyncfgSeqExec executes state-changing dyncfg commands serially.
156 func (d *ServiceDiscovery) dyncfgSeqExec(fn dyncfg.Function) {
163 - // Clear waitCfgOnOff before processing enable/disable
164 - if fn.Command() == dyncfg.CommandEnable || fn.Command() == dyncfg.CommandDisable {
165 - if key, _, ok := d.sdCb.ExtractKey(fn); ok {
166 - if entry, ok := d.exposed.LookupByKey(key); ok {
167 - if entry.Cfg.PipelineKey() == d.waitCfgOnOff {
168 - d.waitCfgOnOff = ""
169 - }
170 - }
171 - }
172 - }
157 + d.handler.SyncDecision(fn)
158
159 switch fn.Command() {
160 case dyncfg.CommandAdd:
@@ -340,13 +325,13 @@ func (d *ServiceDiscovery) extractDiscovererAndName(id string) (discovererType,
325
326 // registerDyncfgTemplates registers dyncfg templates for each discoverer type
327 func (d *ServiceDiscovery) registerDyncfgTemplates(ctx context.Context) {
343 - if d.fnReg == nil || disableDyncfg {
328 + if d.fnReg == nil {
329 return
330 }
331
332 // Register prefix handler for config commands
333 // Wrap to convert functions.Function to dyncfg.Function
349 - d.fnReg.RegisterPrefix("config", d.dyncfgSDPrefixValue(), d.dyncfgConfigHandler)
334 + d.fnReg.RegisterPrefix("config", d.dyncfgSDPrefixValue(), dyncfg.WrapHandler(d.dyncfgConfig))
335
336 // Register templates for each discoverer type
337 for _, dt := range d.discovererRegistry().Types() {
@@ -357,7 +342,7 @@ func (d *ServiceDiscovery) registerDyncfgTemplates(ctx context.Context) {
342
343 // unregisterDyncfgTemplates unregisters dyncfg templates
344 func (d *ServiceDiscovery) unregisterDyncfgTemplates() {
360 - if d.fnReg == nil || disableDyncfg {
345 + if d.fnReg == nil {
346 return
347 }
348
src/go/plugin/agent/discovery/sd/dyncfg_cache.go
+3 -10
@@ -8,6 +8,7 @@ import (
8 "strings"
9
10 "github.com/netdata/netdata/go/plugins/plugin/agent/discovery/sd/pipeline"
11 + "github.com/netdata/netdata/go/plugins/plugin/agent/internal/naming"
12 "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
13
14 "github.com/gohugoio/hashstructure"
@@ -114,14 +115,6 @@ func (c sdConfig) DataJSON() []byte {
115 return b
116 }
117
117 -// cleanName sanitizes a name for use in dyncfg IDs.
118 -// Replaces spaces and colons with underscores to avoid parsing issues.
119 -func cleanName(name string) string {
120 - name = strings.ReplaceAll(name, " ", "_")
121 - name = strings.ReplaceAll(name, ":", "_")
122 - return name
123 -}
124 -
118 // newSDConfigFromYAML creates an sdConfig from YAML bytes.
119 // Used when loading file configs. Cleans the name for dyncfg compatibility.
120 func newSDConfigFromYAML(data []byte, source, sourceType, pipelineKey string) (sdConfig, error) {
@@ -144,7 +137,7 @@ func newSDConfigFromYAML(data []byte, source, sourceType, pipelineKey string) (s
137
138 // Clean the name for dyncfg compatibility
139 if name := m.Name(); name != "" {
147 - m["name"] = cleanName(name)
140 + m["name"] = naming.Sanitize(name)
141 }
142
143 // Add metadata
@@ -170,7 +163,7 @@ func newSDConfigFromJSON(data []byte, name, source, sourceType, discovererType,
163
164 // Force name from dyncfg job ID (matching jobmgr pattern: cfg.SetName(name))
165 // This ensures sdConfig.ExposedKey() matches the dyncfg job ID regardless of payload content
173 - m["name"] = cleanName(name)
166 + m["name"] = naming.Sanitize(name)
167
168 // Add metadata
169 m.SetSource(source)
src/go/plugin/agent/discovery/sd/pipeline/config.go
+3 -4
@@ -9,6 +9,7 @@ import (
9 "strings"
10
11 "github.com/netdata/netdata/go/plugins/plugin/agent/discovery/sd/model"
12 + "github.com/netdata/netdata/go/plugins/plugin/agent/internal/naming"
13 "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
14 )
15
@@ -193,11 +194,9 @@ func normalizeYAMLValue(v any) (any, error) {
194 }
195
196 // CleanName returns the name sanitized for use in dyncfg IDs.
196 -// Replaces spaces and colons to avoid parsing issues.
197 +// Sanitizes for safe use in IDs and paths.
198 func (c Config) CleanName() string {
198 - name := strings.ReplaceAll(c.Name, " ", "_")
199 - name = strings.ReplaceAll(name, ":", "_")
200 - return name
199 + return naming.Sanitize(c.Name)
200 }
201
202 // UnmarshalYAML implements yaml.Unmarshaler.
src/go/plugin/agent/discovery/sd/pipeline/config_test.go
+123 -60
@@ -11,48 +11,95 @@ import (
11 "gopkg.in/yaml.v2"
12 )
13
14 -func TestDiscovererPayload_JSONRoundTrip(t *testing.T) {
15 - input := `{"docker":{"address":"unix:///var/run/docker.sock","timeout":"5s"}}`
16 -
17 - var p DiscovererPayload
18 - require.NoError(t, json.Unmarshal([]byte(input), &p))
19 - require.Equal(t, "docker", p.Type())
20 -
21 - out, err := json.Marshal(p)
22 - require.NoError(t, err)
23 - assert.JSONEq(t, input, string(out))
14 +func TestDiscovererPayload_RoundTrip(t *testing.T) {
15 + tests := map[string]struct {
16 + input string
17 + unmarshal func([]byte, *DiscovererPayload) error
18 + marshal func(DiscovererPayload) ([]byte, error)
19 + assertOut func(*testing.T, string)
20 + wantType string
21 + }{
22 + "json": {
23 + input: `{"docker":{"address":"unix:///var/run/docker.sock","timeout":"5s"}}`,
24 + unmarshal: func(data []byte, p *DiscovererPayload) error {
25 + return json.Unmarshal(data, p)
26 + },
27 + marshal: func(p DiscovererPayload) ([]byte, error) {
28 + return json.Marshal(p)
29 + },
30 + wantType: "docker",
31 + assertOut: func(t *testing.T, out string) {
32 + assert.JSONEq(t, `{"docker":{"address":"unix:///var/run/docker.sock","timeout":"5s"}}`, out)
33 + },
34 + },
35 + "yaml": {
36 + input: "docker:\n address: unix:///var/run/docker.sock\n timeout: 5s\n",
37 + unmarshal: func(data []byte, p *DiscovererPayload) error {
38 + return yaml.Unmarshal(data, p)
39 + },
40 + marshal: func(p DiscovererPayload) ([]byte, error) {
41 + return yaml.Marshal(p)
42 + },
43 + wantType: "docker",
44 + assertOut: func(t *testing.T, out string) {
45 + assert.Contains(t, out, "docker:")
46 + assert.Contains(t, out, "address: unix:///var/run/docker.sock")
47 + assert.Contains(t, out, "timeout: 5s")
48 + },
49 + },
50 + }
51 +
52 + for name, tc := range tests {
53 + t.Run(name, func(t *testing.T) {
54 + var p DiscovererPayload
55 + require.NoError(t, tc.unmarshal([]byte(tc.input), &p))
56 + require.Equal(t, tc.wantType, p.Type())
57 +
58 + out, err := tc.marshal(p)
59 + require.NoError(t, err)
60 + tc.assertOut(t, string(out))
61 + })
62 + }
63 }
64
26 -func TestDiscovererPayload_YAMLRoundTrip(t *testing.T) {
27 - input := "docker:\n address: unix:///var/run/docker.sock\n timeout: 5s\n"
28 -
29 - var p DiscovererPayload
30 - require.NoError(t, yaml.Unmarshal([]byte(input), &p))
31 - require.Equal(t, "docker", p.Type())
32 -
33 - out, err := yaml.Marshal(p)
34 - require.NoError(t, err)
35 - assert.Contains(t, string(out), "docker:")
36 - assert.Contains(t, string(out), "address: unix:///var/run/docker.sock")
37 - assert.Contains(t, string(out), "timeout: 5s")
65 +func TestDiscovererPayload_RejectsMultipleDiscoverers(t *testing.T) {
66 + tests := map[string]struct {
67 + input string
68 + unmarshal func([]byte, *DiscovererPayload) error
69 + }{
70 + "json": {
71 + input: `{"docker":{},"snmp":{}}`,
72 + unmarshal: func(data []byte, p *DiscovererPayload) error {
73 + return json.Unmarshal(data, p)
74 + },
75 + },
76 + "yaml": {
77 + input: "docker: {}\nsnmp: {}\n",
78 + unmarshal: func(data []byte, p *DiscovererPayload) error {
79 + return yaml.Unmarshal(data, p)
80 + },
81 + },
82 + }
83 +
84 + for name, tc := range tests {
85 + t.Run(name, func(t *testing.T) {
86 + var p DiscovererPayload
87 + err := tc.unmarshal([]byte(tc.input), &p)
88 + require.Error(t, err)
89 + assert.Contains(t, err.Error(), "multiple discoverers configured")
90 + })
91 + }
92 }
93
40 -func TestDiscovererPayload_RejectsMultipleDiscoverersJSON(t *testing.T) {
41 - var p DiscovererPayload
42 - err := json.Unmarshal([]byte(`{"docker":{},"snmp":{}}`), &p)
43 - require.Error(t, err)
44 - assert.Contains(t, err.Error(), "multiple discoverers configured")
45 -}
46 -
47 -func TestDiscovererPayload_RejectsMultipleDiscoverersYAML(t *testing.T) {
48 - var p DiscovererPayload
49 - err := yaml.Unmarshal([]byte("docker: {}\nsnmp: {}\n"), &p)
50 - require.Error(t, err)
51 - assert.Contains(t, err.Error(), "multiple discoverers configured")
52 -}
53 -
54 -func TestConfig_UnmarshalYAMLLegacyDiscoverK8sMerge(t *testing.T) {
55 - input := `
94 +func TestConfig_UnmarshalYAMLLegacyDiscover(t *testing.T) {
95 + tests := map[string]struct {
96 + input string
97 + wantErr bool
98 + wantErrContain []string
99 + assertCfg func(*testing.T, Config)
100 + }{
101 + "k8s merge": {
102 + input: `
103 name: test-k8s
104 discover:
105 - discoverer: k8s
@@ -66,21 +113,19 @@ discover:
113 services:
114 - id: "test-rule"
115 match: "true"
69 -`
70 -
71 - var cfg Config
72 - require.NoError(t, yaml.Unmarshal([]byte(input), &cfg))
73 - require.Equal(t, "k8s", cfg.Discoverer.Type())
74 -
75 - var got []map[string]any
76 - require.NoError(t, json.Unmarshal(cfg.Discoverer.Config, &got))
77 - require.Len(t, got, 2)
78 - assert.Equal(t, "pod", got[0]["role"])
79 - assert.Equal(t, "service", got[1]["role"])
80 -}
81 -
82 -func TestConfig_UnmarshalYAMLLegacyDiscoverMissingConfigFails(t *testing.T) {
83 - input := `
116 +`,
117 + assertCfg: func(t *testing.T, cfg Config) {
118 + require.Equal(t, "k8s", cfg.Discoverer.Type())
119 +
120 + var got []map[string]any
121 + require.NoError(t, json.Unmarshal(cfg.Discoverer.Config, &got))
122 + require.Len(t, got, 2)
123 + assert.Equal(t, "pod", got[0]["role"])
124 + assert.Equal(t, "service", got[1]["role"])
125 + },
126 + },
127 + "missing discoverer config fails": {
128 + input: `
129 name: test-invalid
130 discover:
131 - discoverer: docker
@@ -88,11 +133,29 @@ discover:
133 services:
134 - id: "test-rule"
135 match: "true"
91 -`
92 -
93 - var cfg Config
94 - err := yaml.Unmarshal([]byte(input), &cfg)
95 - require.Error(t, err)
96 - assert.Contains(t, err.Error(), "missing config for discoverer")
97 - assert.Contains(t, err.Error(), "docker")
136 +`,
137 + wantErr: true,
138 + wantErrContain: []string{"missing config for discoverer", "docker"},
139 + },
140 + }
141 +
142 + for name, tc := range tests {
143 + t.Run(name, func(t *testing.T) {
144 + var cfg Config
145 + err := yaml.Unmarshal([]byte(tc.input), &cfg)
146 +
147 + if tc.wantErr {
148 + require.Error(t, err)
149 + for _, s := range tc.wantErrContain {
150 + assert.Contains(t, err.Error(), s)
151 + }
152 + return
153 + }
154 +
155 + require.NoError(t, err)
156 + if tc.assertCfg != nil {
157 + tc.assertCfg(t, cfg)
158 + }
159 + })
160 + }
161 }
src/go/plugin/agent/discovery/sd/pipeline/selector_test.go
+106 -147
@@ -3,6 +3,7 @@
3 package pipeline
4
5 import (
6 + "fmt"
7 "regexp"
8 "testing"
9
@@ -13,183 +14,141 @@ import (
14
15 var reSrString = regexp.MustCompile(`^{[^{}]+}$`)
16
16 -func TestTrueSelector_String(t *testing.T) {
17 - var sr trueSelector
18 - assert.Equal(t, "{*}", sr.String())
19 -}
20 -
21 -func TestExactSelector_String(t *testing.T) {
22 - sr := exactSelector("selector")
23 -
24 - assert.True(t, reSrString.MatchString(sr.String()))
25 -}
26 -
27 -func TestNegSelector_String(t *testing.T) {
28 - srs := []selector{
29 - exactSelector("selector"),
30 - negSelector{exactSelector("selector")},
31 - orSelector{
32 - lhs: exactSelector("selector"),
33 - rhs: exactSelector("selector")},
34 - orSelector{
35 - lhs: orSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}},
36 - rhs: orSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}},
37 - },
38 - andSelector{
39 - lhs: andSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}},
40 - rhs: andSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}},
41 - },
42 - }
43 -
44 - for i, sr := range srs {
45 - neg := negSelector{sr}
46 - assert.True(t, reSrString.MatchString(neg.String()), "selector num %d", i+1)
47 - }
48 -}
49 -
50 -func TestOrSelector_String(t *testing.T) {
51 - sr := orSelector{
52 - lhs: orSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}},
53 - rhs: orSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}},
54 - }
55 -
56 - assert.True(t, reSrString.MatchString(sr.String()))
57 -}
58 -
59 -func TestAndSelector_String(t *testing.T) {
60 - sr := andSelector{
61 - lhs: andSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}},
62 - rhs: andSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}},
63 - }
64 -
65 - assert.True(t, reSrString.MatchString(sr.String()))
66 -}
67 -
68 -func TestExactSelector_Matches(t *testing.T) {
69 - matchTests := struct {
70 - tags model.Tags
71 - srs []exactSelector
17 +func TestSelector_String(t *testing.T) {
18 + tests := map[string]struct {
19 + sr selector
20 + want string
21 + wantRegexForm bool
22 }{
73 - tags: model.Tags{"a": {}, "b": {}},
74 - srs: []exactSelector{
75 - "a",
76 - "b",
23 + "true selector": {
24 + sr: trueSelector{},
25 + want: "{*}",
26 },
78 - }
79 - notMatchTests := struct {
80 - tags model.Tags
81 - srs []exactSelector
82 - }{
83 - tags: model.Tags{"a": {}, "b": {}},
84 - srs: []exactSelector{
85 - "c",
86 - "d",
27 + "exact selector": {
28 + sr: exactSelector("selector"),
29 + wantRegexForm: true,
30 },
88 - }
89 -
90 - for i, sr := range matchTests.srs {
91 - assert.Truef(t, sr.matches(matchTests.tags), "match selector num %d", i+1)
92 - }
93 - for i, sr := range notMatchTests.srs {
94 - assert.Falsef(t, sr.matches(notMatchTests.tags), "not match selector num %d", i+1)
95 - }
96 -}
97 -
98 -func TestNegSelector_Matches(t *testing.T) {
99 - matchTests := struct {
100 - tags model.Tags
101 - srs []negSelector
102 - }{
103 - tags: model.Tags{"a": {}, "b": {}},
104 - srs: []negSelector{
105 - {exactSelector("c")},
106 - {exactSelector("d")},
31 + "neg selector from exact": {
32 + sr: negSelector{exactSelector("selector")},
33 + wantRegexForm: true,
34 },
108 - }
109 - notMatchTests := struct {
110 - tags model.Tags
111 - srs []negSelector
112 - }{
113 - tags: model.Tags{"a": {}, "b": {}},
114 - srs: []negSelector{
115 - {exactSelector("a")},
116 - {exactSelector("b")},
35 + "neg selector from neg": {
36 + sr: negSelector{negSelector{exactSelector("selector")}},
37 + wantRegexForm: true,
38 + },
39 + "neg selector from or": {
40 + sr: negSelector{orSelector{
41 + lhs: exactSelector("selector"),
42 + rhs: exactSelector("selector"),
43 + }},
44 + wantRegexForm: true,
45 + },
46 + "neg selector from nested or": {
47 + sr: negSelector{orSelector{
48 + lhs: orSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}},
49 + rhs: orSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}},
50 + }},
51 + wantRegexForm: true,
52 + },
53 + "neg selector from nested and": {
54 + sr: negSelector{andSelector{
55 + lhs: andSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}},
56 + rhs: andSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}},
57 + }},
58 + wantRegexForm: true,
59 + },
60 + "or selector": {
61 + sr: orSelector{
62 + lhs: orSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}},
63 + rhs: orSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}},
64 + },
65 + wantRegexForm: true,
66 + },
67 + "and selector": {
68 + sr: andSelector{
69 + lhs: andSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}},
70 + rhs: andSelector{lhs: exactSelector("selector"), rhs: negSelector{exactSelector("selector")}},
71 + },
72 + wantRegexForm: true,
73 },
74 }
75
120 - for i, sr := range matchTests.srs {
121 - assert.Truef(t, sr.matches(matchTests.tags), "match selector num %d", i+1)
122 - }
123 - for i, sr := range notMatchTests.srs {
124 - assert.Falsef(t, sr.matches(notMatchTests.tags), "not match selector num %d", i+1)
76 + for name, tc := range tests {
77 + t.Run(name, func(t *testing.T) {
78 + got := fmt.Sprintf("%s", tc.sr)
79 + if tc.wantRegexForm {
80 + assert.True(t, reSrString.MatchString(got))
81 + return
82 + }
83 + assert.Equal(t, tc.want, got)
84 + })
85 }
86 }
87
128 -func TestOrSelector_Matches(t *testing.T) {
129 - matchTests := struct {
88 +func TestSelector_Matches(t *testing.T) {
89 + tests := map[string]struct {
90 + sr selector
91 tags model.Tags
131 - srs []orSelector
92 + want bool
93 }{
133 - tags: model.Tags{"a": {}, "b": {}},
134 - srs: []orSelector{
135 - {
94 + "exact selector matches": {
95 + sr: exactSelector("a"),
96 + tags: model.Tags{"a": {}, "b": {}},
97 + want: true,
98 + },
99 + "exact selector does not match": {
100 + sr: exactSelector("c"),
101 + tags: model.Tags{"a": {}, "b": {}},
102 + want: false,
103 + },
104 + "neg selector matches": {
105 + sr: negSelector{exactSelector("c")},
106 + tags: model.Tags{"a": {}, "b": {}},
107 + want: true,
108 + },
109 + "neg selector does not match": {
110 + sr: negSelector{exactSelector("a")},
111 + tags: model.Tags{"a": {}, "b": {}},
112 + want: false,
113 + },
114 + "or selector matches": {
115 + sr: orSelector{
116 lhs: orSelector{lhs: exactSelector("c"), rhs: exactSelector("d")},
117 rhs: orSelector{lhs: exactSelector("e"), rhs: exactSelector("b")},
118 },
119 + tags: model.Tags{"a": {}, "b": {}},
120 + want: true,
121 },
140 - }
141 - notMatchTests := struct {
142 - tags model.Tags
143 - srs []orSelector
144 - }{
145 - tags: model.Tags{"a": {}, "b": {}},
146 - srs: []orSelector{
147 - {
122 + "or selector does not match": {
123 + sr: orSelector{
124 lhs: orSelector{lhs: exactSelector("c"), rhs: exactSelector("d")},
125 rhs: orSelector{lhs: exactSelector("e"), rhs: exactSelector("f")},
126 },
127 + tags: model.Tags{"a": {}, "b": {}},
128 + want: false,
129 },
152 - }
153 -
154 - for i, sr := range matchTests.srs {
155 - assert.Truef(t, sr.matches(matchTests.tags), "match selector num %d", i+1)
156 - }
157 - for i, sr := range notMatchTests.srs {
158 - assert.Falsef(t, sr.matches(notMatchTests.tags), "not match selector num %d", i+1)
159 - }
160 -}
161 -
162 -func TestAndSelector_Matches(t *testing.T) {
163 - matchTests := struct {
164 - tags model.Tags
165 - srs []andSelector
166 - }{
167 - tags: model.Tags{"a": {}, "b": {}, "c": {}, "d": {}},
168 - srs: []andSelector{
169 - {
130 + "and selector matches": {
131 + sr: andSelector{
132 lhs: andSelector{lhs: exactSelector("a"), rhs: exactSelector("b")},
133 rhs: andSelector{lhs: exactSelector("c"), rhs: exactSelector("d")},
134 },
135 + tags: model.Tags{"a": {}, "b": {}, "c": {}, "d": {}},
136 + want: true,
137 },
174 - }
175 - notMatchTests := struct {
176 - tags model.Tags
177 - srs []andSelector
178 - }{
179 - tags: model.Tags{"a": {}, "b": {}, "c": {}, "d": {}},
180 - srs: []andSelector{
181 - {
138 + "and selector does not match": {
139 + sr: andSelector{
140 lhs: andSelector{lhs: exactSelector("a"), rhs: exactSelector("b")},
141 rhs: andSelector{lhs: exactSelector("c"), rhs: exactSelector("z")},
142 },
143 + tags: model.Tags{"a": {}, "b": {}, "c": {}, "d": {}},
144 + want: false,
145 },
146 }
147
188 - for i, sr := range matchTests.srs {
189 - assert.Truef(t, sr.matches(matchTests.tags), "match selector num %d", i+1)
190 - }
191 - for i, sr := range notMatchTests.srs {
192 - assert.Falsef(t, sr.matches(notMatchTests.tags), "not match selector num %d", i+1)
148 + for name, tc := range tests {
149 + t.Run(name, func(t *testing.T) {
150 + assert.Equal(t, tc.want, tc.sr.matches(tc.tags))
151 + })
152 }
153 }
154
src/go/plugin/agent/discovery/sd/sd.go
+37 -89
@@ -6,30 +6,21 @@ import (
6 "context"
7 "fmt"
8 "log/slog"
9 - "os"
9 "sync"
10
12 - "github.com/netdata/netdata/go/plugins/logger"
13 - "github.com/netdata/netdata/go/plugins/pkg/executable"
14 - "github.com/netdata/netdata/go/plugins/pkg/multipath"
15 - "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
16 - "github.com/netdata/netdata/go/plugins/pkg/safewriter"
11 "github.com/netdata/netdata/go/plugins/plugin/agent/discovery/sd/pipeline"
12 + "github.com/netdata/netdata/go/plugins/plugin/agent/internal/terminal"
13 "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
14 "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
15 "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
16
22 - "github.com/mattn/go-isatty"
17 + "github.com/netdata/netdata/go/plugins/logger"
18 + "github.com/netdata/netdata/go/plugins/pkg/executable"
19 + "github.com/netdata/netdata/go/plugins/pkg/multipath"
20 + "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
21 + "github.com/netdata/netdata/go/plugins/pkg/safewriter"
22 )
23
25 -var isTerminal = isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsTerminal(os.Stdin.Fd())
26 -
27 -// disableDyncfg controls whether SD dyncfg integration is active.
28 -// When true (default): templates are not registered, file configs auto-start without dyncfg.
29 -// When false: full dyncfg integration (used in tests).
30 -// TODO: Remove this flag after SD dyncfg feature is validated in production.
31 -var disableDyncfg = false
32 -
24 type Config struct {
25 ConfigDefaults confgroup.Registry
26 ConfDir multipath.MultiPath
@@ -66,6 +57,9 @@ func NewServiceDiscovery(cfg Config) (*ServiceDiscovery, error) {
57 Seen: d.seen,
58 Exposed: d.exposed,
59 Callbacks: d.sdCb,
60 + WaitKey: func(cfg sdConfig) string {
61 + return cfg.PipelineKey()
62 + },
63
64 Path: fmt.Sprintf(dyncfgSDPath, executable.Name),
65 EnableFailCode: 422,
@@ -102,11 +96,6 @@ type (
96
97 ctx context.Context
98 mgr *PipelineManager
105 -
106 - // waitCfgOnOff holds the pipeline key we're waiting for enable/disable on.
107 - // When set, we only process dyncfg commands (not new file configs).
108 - // This ensures netdata can send enable/disable before we process more configs.
109 - waitCfgOnOff string
99 }
100 sdPipeline interface {
101 Run(ctx context.Context, in chan<- []*confgroup.Group)
@@ -119,10 +108,7 @@ type (
108
109 // SetDyncfgResponder allows overriding the default responder (e.g., to silence output in tests).
110 func (d *ServiceDiscovery) SetDyncfgResponder(api *dyncfg.Responder) {
122 - if api != nil {
123 - d.dyncfgApi = api
124 - d.handler.SetAPI(api)
125 - }
111 + dyncfg.BindResponder(&d.dyncfgApi, d.handler, api)
112 }
113
114 func (d *ServiceDiscovery) String() string {
@@ -170,7 +156,7 @@ func (d *ServiceDiscovery) Run(ctx context.Context, in chan<- []*confgroup.Group
156
157 func (d *ServiceDiscovery) run(ctx context.Context) {
158 for {
173 - if d.waitCfgOnOff != "" {
159 + if d.handler.WaitingForDecision() {
160 // Waiting for enable/disable command - only process dyncfg commands
161 select {
162 case <-ctx.Done():
@@ -215,12 +201,9 @@ func (d *ServiceDiscovery) removePipeline(conf confFile) {
201 d.Infof("removing %d config(s) from source '%s'", len(seenCfgs), conf.source)
202
203 for _, scfg := range seenCfgs {
218 - // Remove from seen cache
219 - d.seen.Remove(scfg)
220 -
221 - // Check if this was the exposed config
222 - entry, ok := d.exposed.LookupByKey(scfg.ExposedKey())
223 - if !ok || entry.Cfg.UID() != scfg.UID() {
204 + // Remove from seen/exposed caches if this config is currently tracked.
205 + _, ok := d.handler.RemoveDiscoveredConfig(scfg)
206 + if !ok {
207 // Not exposed or different config is exposed - skip dyncfg remove
208 continue
209 }
@@ -230,10 +213,7 @@ func (d *ServiceDiscovery) removePipeline(conf confFile) {
213 d.mgr.Stop(scfg.PipelineKey())
214 }
215
233 - d.exposed.Remove(scfg)
234 - if !disableDyncfg {
235 - d.handler.NotifyJobRemove(scfg)
236 - }
216 + d.handler.NotifyJobRemove(scfg)
217 }
218 }
219
@@ -276,28 +256,25 @@ func (d *ServiceDiscovery) addConfig(ctx context.Context, scfg sdConfig) {
256 d.removeOldConfigsFromSource(scfg.Source(), scfg.ExposedKey())
257 }
258
279 - // Always add to seen cache
280 - d.seen.Add(scfg)
259 + // Always remember discovered configs, even if they are not exposed.
260 + d.handler.RememberDiscoveredConfig(scfg)
261
262 // Check if there's an existing exposed config with the same key
263 entry, exists := d.exposed.LookupByKey(scfg.ExposedKey())
264
265 if !exists {
266 // No existing config - expose this one
287 - d.exposed.Add(&dyncfg.Entry[sdConfig]{Cfg: scfg, Status: dyncfg.StatusAccepted})
267 + d.handler.AddDiscoveredConfig(scfg, dyncfg.StatusAccepted)
268
289 - if disableDyncfg {
290 - // Dyncfg disabled - start pipeline directly
291 - d.startPipelineDirectly(ctx, scfg)
269 + d.handler.NotifyJobCreate(scfg, dyncfg.StatusAccepted)
270 + if terminal.IsTerminal() || d.fnReg == nil || d.dyncfgCh == nil {
271 + // Auto-enable in terminal mode and tests.
272 + // Also auto-enable when no function registry is attached, because
273 + // no external enable/disable commands can be delivered.
274 + d.autoEnableConfig(scfg)
275 } else {
293 - d.handler.NotifyJobCreate(scfg, dyncfg.StatusAccepted)
294 - if isTerminal || d.dyncfgCh == nil {
295 - // Auto-enable in terminal mode or tests
296 - d.autoEnableConfig(scfg)
297 - } else {
298 - // Wait for netdata to send enable/disable
299 - d.waitCfgOnOff = scfg.PipelineKey()
300 - }
276 + // Wait for netdata to send enable/disable
277 + d.handler.WaitForDecision(scfg)
278 }
279 return
280 }
@@ -320,21 +297,16 @@ func (d *ServiceDiscovery) addConfig(ctx context.Context, scfg sdConfig) {
297 }
298
299 // Replace in exposed cache
323 - d.exposed.Add(&dyncfg.Entry[sdConfig]{Cfg: scfg, Status: dyncfg.StatusAccepted})
300 + d.handler.AddDiscoveredConfig(scfg, dyncfg.StatusAccepted)
301
325 - if disableDyncfg {
326 - // Dyncfg disabled - start pipeline directly
327 - d.startPipelineDirectly(ctx, scfg)
328 - } else {
329 - // Update dyncfg (remove old, create new with new source)
330 - d.handler.NotifyJobRemove(entry.Cfg)
331 - d.handler.NotifyJobCreate(scfg, dyncfg.StatusAccepted)
302 + // Update dyncfg (remove old, create new with new source)
303 + d.handler.NotifyJobRemove(entry.Cfg)
304 + d.handler.NotifyJobCreate(scfg, dyncfg.StatusAccepted)
305
333 - if isTerminal || d.dyncfgCh == nil {
334 - d.autoEnableConfig(scfg)
335 - } else {
336 - d.waitCfgOnOff = scfg.PipelineKey()
337 - }
306 + if terminal.IsTerminal() || d.fnReg == nil || d.dyncfgCh == nil {
307 + d.autoEnableConfig(scfg)
308 + } else {
309 + d.handler.WaitForDecision(scfg)
310 }
311 }
312
@@ -359,38 +331,14 @@ func (d *ServiceDiscovery) removeOldConfigsFromSource(source, newKey string) {
331 }
332
333 // Different config from same source - remove from caches
362 - d.seen.Remove(oldCfg)
363 -
364 - // If it was exposed, remove from exposed cache and dyncfg
334 + // If it was exposed, remove from exposed cache and dyncfg.
335 // But DON'T stop the pipeline - let the new config's enable handle that
366 - if entry, ok := d.exposed.LookupByKey(oldCfg.ExposedKey()); ok && entry.Cfg.UID() == oldCfg.UID() {
367 - d.exposed.Remove(oldCfg)
368 - if !disableDyncfg {
369 - d.handler.NotifyJobRemove(oldCfg)
370 - }
336 + if _, ok := d.handler.RemoveDiscoveredConfig(oldCfg); ok {
337 + d.handler.NotifyJobRemove(oldCfg)
338 }
339 }
340 }
341
375 -// startPipelineDirectly starts a pipeline without dyncfg integration.
376 -// Used when disableDyncfg is true.
377 -func (d *ServiceDiscovery) startPipelineDirectly(ctx context.Context, cfg sdConfig) {
378 - pipelineCfg, err := cfg.ToPipelineConfig(d.configDefaults)
379 - if err != nil {
380 - d.Errorf("failed to parse config '%s': %v", cfg.Name(), err)
381 - return
382 - }
383 -
384 - if err := d.mgr.Start(ctx, cfg.PipelineKey(), pipelineCfg); err != nil {
385 - d.Errorf("failed to start pipeline '%s': %v", cfg.Name(), err)
386 - return
387 - }
388 -
389 - if entry, ok := d.exposed.LookupByKey(cfg.ExposedKey()); ok {
390 - entry.Status = dyncfg.StatusRunning
391 - }
392 -}
393 -
342 // pipelineKeyFromSource extracts a pipeline key from a file source path.
343 // For now, we use the file path as key. This will be extended for dyncfg.
344 func pipelineKeyFromSource(source string) string {
src/go/plugin/agent/discovery/sd/sd_test.go
-5
@@ -12,11 +12,6 @@ import (
12 "gopkg.in/yaml.v2"
13 )
14
15 -func init() {
16 - // Enable dyncfg integration for tests (disabled by default in production)
17 - disableDyncfg = false
18 -}
19 -
15 func TestServiceDiscovery_Run(t *testing.T) {
16 tests := map[string]discoverySim{
17 "add pipeline": {
src/go/plugin/agent/dump.go
+2 -1781
@@ -2,1784 +2,5 @@
2
3 package agent
4
5 -import (
6 - "encoding/json"
7 - "fmt"
8 - "os"
9 - "path/filepath"
10 - "sort"
11 - "strings"
12 - "sync"
13 - "time"
14 -
15 - "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
16 -)
17 -
18 -// DumpAnalyzer collects and analyzes metric structure from dump mode
19 -type DumpAnalyzer struct {
20 - mu sync.RWMutex
21 - jobs map[string]*JobAnalysis // key: job name
22 - startTime time.Time
23 - dataDir string
24 - jobDirs map[string]string
25 - jobDone map[string]bool
26 - onComplete func()
27 - completed bool
28 -}
29 -
30 -// JobAnalysis holds analysis for a single job
31 -type JobAnalysis struct {
32 - Name string
33 - Module string
34 - Charts []ChartAnalysis
35 - CollectionCount int
36 - LastCollection time.Time
37 - AllSeenMetrics map[string]bool // Track ALL metrics seen in mx map
38 -}
39 -
40 -// ChartAnalysis holds analysis for a single chart
41 -type ChartAnalysis struct {
42 - Chart *collectorapi.Chart
43 - CollectedValues map[string][]int64 // dimension ID -> collected values
44 - SeenDimensions map[string]bool // track which dimensions received data
45 -}
46 -
47 -// NewDumpAnalyzer creates a new dump analyzer
48 -func NewDumpAnalyzer() *DumpAnalyzer {
49 - return &DumpAnalyzer{
50 - jobs: make(map[string]*JobAnalysis),
51 - startTime: time.Now(),
52 - jobDirs: make(map[string]string),
53 - jobDone: make(map[string]bool),
54 - }
55 -}
56 -
57 -// EnableDataCapture configures the analyzer to persist dump artifacts.
58 -func (da *DumpAnalyzer) EnableDataCapture(dir string, onComplete func()) {
59 - da.mu.Lock()
60 - defer da.mu.Unlock()
61 - da.dataDir = dir
62 - da.onComplete = onComplete
63 -}
64 -
65 -// RegisterJob registers directory info for a job.
66 -func (da *DumpAnalyzer) RegisterJob(jobName, moduleName, dir string) {
67 - da.mu.Lock()
68 - defer da.mu.Unlock()
69 - if dir == "" {
70 - return
71 - }
72 - if da.jobDirs == nil {
73 - da.jobDirs = make(map[string]string)
74 - }
75 - da.jobDirs[jobName] = dir
76 - if da.jobDone == nil {
77 - da.jobDone = make(map[string]bool)
78 - }
79 - da.jobDone[jobName] = false
80 - // Ensure expected sub-directories exist
81 - _ = os.MkdirAll(filepath.Join(dir, "queries"), 0o755)
82 - _ = os.MkdirAll(filepath.Join(dir, "rows"), 0o755)
83 - _ = os.MkdirAll(filepath.Join(dir, "metrics"), 0o755)
84 - _ = os.MkdirAll(filepath.Join(dir, "meta"), 0o755)
85 -}
86 -
87 -// RecordJobStructure records the initial chart structure for a job
88 -func (da *DumpAnalyzer) RecordJobStructure(jobName, moduleName string, charts *collectorapi.Charts) {
89 - da.mu.Lock()
90 - defer da.mu.Unlock()
91 -
92 - job := &JobAnalysis{
93 - Name: jobName,
94 - Module: moduleName,
95 - Charts: make([]ChartAnalysis, 0),
96 - AllSeenMetrics: make(map[string]bool),
97 - }
98 -
99 - // Copy chart structure
100 - for _, chart := range *charts {
101 - ca := ChartAnalysis{
102 - Chart: chart,
103 - CollectedValues: make(map[string][]int64),
104 - SeenDimensions: make(map[string]bool),
105 - }
106 -
107 - // Initialize dimension tracking
108 - for _, dim := range chart.Dims {
109 - ca.CollectedValues[dim.ID] = make([]int64, 0)
110 - ca.SeenDimensions[dim.ID] = false
111 - }
112 -
113 - job.Charts = append(job.Charts, ca)
114 - }
115 -
116 - da.jobs[jobName] = job
117 - da.writeJobMetadata(jobName, moduleName)
118 -}
119 -
120 -// UpdateJobStructure updates the chart structure for a job with current charts
121 -// This is needed for collectors that create charts dynamically during collection
122 -func (da *DumpAnalyzer) UpdateJobStructure(jobName string, charts *collectorapi.Charts) {
123 - da.mu.Lock()
124 - defer da.mu.Unlock()
125 -
126 - job, exists := da.jobs[jobName]
127 - if !exists {
128 - return // Job not found, cannot update
129 - }
130 -
131 - // Create a map of existing chart data to preserve collected values
132 - existingCharts := make(map[string]*ChartAnalysis)
133 - for i := range job.Charts {
134 - existingCharts[job.Charts[i].Chart.ID] = &job.Charts[i]
135 - }
136 -
137 - // Rebuild chart list while preserving existing data
138 - job.Charts = make([]ChartAnalysis, 0)
139 -
140 - // Copy current chart structure
141 - for _, chart := range *charts {
142 - var ca ChartAnalysis
143 -
144 - // Check if we have existing data for this chart
145 - if existing, exists := existingCharts[chart.ID]; exists {
146 - // Preserve existing chart analysis but update the chart reference
147 - ca = *existing
148 - ca.Chart = chart
149 -
150 - // Add any new dimensions that weren't tracked before
151 - for _, dim := range chart.Dims {
152 - if _, tracked := ca.CollectedValues[dim.ID]; !tracked {
153 - ca.CollectedValues[dim.ID] = make([]int64, 0)
154 - ca.SeenDimensions[dim.ID] = false
155 - }
156 - }
157 - } else {
158 - // New chart - create fresh tracking
159 - ca = ChartAnalysis{
160 - Chart: chart,
161 - CollectedValues: make(map[string][]int64),
162 - SeenDimensions: make(map[string]bool),
163 - }
164 -
165 - // Initialize dimension tracking
166 - for _, dim := range chart.Dims {
167 - ca.CollectedValues[dim.ID] = make([]int64, 0)
168 - ca.SeenDimensions[dim.ID] = false
169 - }
170 - }
171 -
172 - job.Charts = append(job.Charts, ca)
173 - }
174 -}
175 -
176 -// RecordCollection records collected metrics directly from structured data
177 -func (da *DumpAnalyzer) RecordCollection(jobName string, mx map[string]int64) {
178 - da.mu.Lock()
179 - defer da.mu.Unlock()
180 -
181 - job, exists := da.jobs[jobName]
182 - if !exists {
183 - return
184 - }
185 -
186 - job.CollectionCount++
187 - job.LastCollection = time.Now()
188 -
189 - // Track ALL metrics in mx map
190 - for metricID := range mx {
191 - job.AllSeenMetrics[metricID] = true
192 - }
193 -
194 - // Record values for each chart
195 - for i := range job.Charts {
196 - ca := &job.Charts[i]
197 -
198 - // Check each dimension in this chart
199 - for _, dim := range ca.Chart.Dims {
200 - if value, collected := mx[dim.ID]; collected {
201 - ca.SeenDimensions[dim.ID] = true
202 - ca.CollectedValues[dim.ID] = append(ca.CollectedValues[dim.ID], value)
203 - }
204 - }
205 - }
206 -
207 - da.writeMetrics(jobName, job.CollectionCount, mx)
208 - da.markJobCollected(jobName)
209 -}
210 -
211 -// PrintReport prints the analysis report
212 -func (da *DumpAnalyzer) PrintReport() {
213 - da.mu.RLock()
214 - defer da.mu.RUnlock()
215 -
216 - // Sort jobs for consistent output
217 - var jobNames []string
218 - for name := range da.jobs {
219 - jobNames = append(jobNames, name)
220 - }
221 - sort.Strings(jobNames)
222 -
223 - for _, jobName := range jobNames {
224 - job := da.jobs[jobName]
225 - da.printJobAnalysis(job)
226 - }
227 -}
228 -
229 -// PrintSummary prints a consolidated summary across all jobs
230 -func (da *DumpAnalyzer) PrintSummary() {
231 - da.mu.RLock()
232 - defer da.mu.RUnlock()
233 -
234 - // First print the regular report
235 - da.PrintReport()
236 -
237 - // Then print the consolidated summary
238 - fmt.Println("\n" + strings.Repeat("═", 80))
239 - fmt.Println("CONSOLIDATED SUMMARY ACROSS ALL JOBS")
240 - fmt.Println(strings.Repeat("═", 80))
241 -
242 - // Collect all contexts across all jobs
243 - type contextSummary struct {
244 - family string
245 - context string
246 - title string
247 - units string
248 - priority int
249 - chartType string
250 - labelKeys []string
251 - dimNames []string
252 - instances int
253 - jobs map[string]bool
254 - }
255 -
256 - contextMap := make(map[string]*contextSummary) // context -> summary
257 -
258 - for jobName, job := range da.jobs {
259 - for i := range job.Charts {
260 - ca := &job.Charts[i]
261 -
262 - ctx := ca.Chart.Ctx
263 - if _, exists := contextMap[ctx]; !exists {
264 - // Collect unique label keys
265 - labelKeysMap := make(map[string]bool)
266 - for _, label := range ca.Chart.Labels {
267 - labelKeysMap[label.Key] = true
268 - }
269 - labelKeys := []string{}
270 - for key := range labelKeysMap {
271 - labelKeys = append(labelKeys, key)
272 - }
273 - sort.Strings(labelKeys)
274 -
275 - // Collect unique dimension names
276 - dimNamesMap := make(map[string]bool)
277 - for _, dim := range ca.Chart.Dims {
278 - dimName := dim.Name
279 - if dimName == "" {
280 - dimName = dim.ID
281 - }
282 - dimNamesMap[dimName] = true
283 - }
284 - dimNames := []string{}
285 - for name := range dimNamesMap {
286 - dimNames = append(dimNames, name)
287 - }
288 - sort.Strings(dimNames)
289 -
290 - contextMap[ctx] = &contextSummary{
291 - family: ca.Chart.Fam,
292 - context: ctx,
293 - title: ca.Chart.Title,
294 - units: ca.Chart.Units,
295 - priority: ca.Chart.Priority,
296 - chartType: ca.Chart.Type.String(),
297 - labelKeys: labelKeys,
298 - dimNames: dimNames,
299 - instances: 0,
300 - jobs: make(map[string]bool),
301 - }
302 - }
303 -
304 - // Update instance count and job tracking
305 - contextMap[ctx].instances++
306 - contextMap[ctx].jobs[jobName] = true
307 -
308 - // Update label keys and dimension names if needed
309 - for _, label := range ca.Chart.Labels {
310 - found := false
311 - for _, key := range contextMap[ctx].labelKeys {
312 - if key == label.Key {
313 - found = true
314 - break
315 - }
316 - }
317 - if !found {
318 - contextMap[ctx].labelKeys = append(contextMap[ctx].labelKeys, label.Key)
319 - sort.Strings(contextMap[ctx].labelKeys)
320 - }
321 - }
322 -
323 - for _, dim := range ca.Chart.Dims {
324 - dimName := dim.Name
325 - if dimName == "" {
326 - dimName = dim.ID
327 - }
328 - found := false
329 - for _, name := range contextMap[ctx].dimNames {
330 - if name == dimName {
331 - found = true
332 - break
333 - }
334 - }
335 - if !found {
336 - contextMap[ctx].dimNames = append(contextMap[ctx].dimNames, dimName)
337 - sort.Strings(contextMap[ctx].dimNames)
338 - }
339 - }
340 - }
341 - }
342 -
343 - // Group contexts by family
344 - familyMap := make(map[string][]*contextSummary)
345 - for _, cs := range contextMap {
346 - family := cs.family
347 - if family == "" {
348 - family = "(no family)"
349 - }
350 - familyMap[family] = append(familyMap[family], cs)
351 - }
352 -
353 - // Sort families by their minimum priority (priority of their lowest-priority context)
354 - type familyPriority struct {
355 - family string
356 - minPriority int
357 - }
358 - var familyPriorities []familyPriority
359 - for fam, contexts := range familyMap {
360 - minPrio := contexts[0].priority
361 - for _, ctx := range contexts {
362 - if ctx.priority < minPrio {
363 - minPrio = ctx.priority
364 - }
365 - }
366 - familyPriorities = append(familyPriorities, familyPriority{family: fam, minPriority: minPrio})
367 - }
368 - sort.Slice(familyPriorities, func(i, j int) bool {
369 - return familyPriorities[i].minPriority < familyPriorities[j].minPriority
370 - })
371 -
372 - var families []string
373 - for _, fp := range familyPriorities {
374 - families = append(families, fp.family)
375 - }
376 -
377 - // Print summary with tree structure using colons
378 - for i, family := range families {
379 - if i == 0 {
380 - fmt.Printf("\n┌─ family: %s\n", family)
381 - } else {
382 - fmt.Printf("\n├─ family: %s\n", family)
383 - }
384 -
385 - // Sort contexts by priority
386 - contexts := familyMap[family]
387 - sort.Slice(contexts, func(i, j int) bool {
388 - return contexts[i].priority < contexts[j].priority
389 - })
390 -
391 - for j, cs := range contexts {
392 - isLastContext := j == len(contexts)-1
393 - contextPrefix := "├──"
394 - detailPrefix := "│ ├─"
395 - lastDetailPrefix := "│ └─"
396 -
397 - if isLastContext {
398 - contextPrefix = "└──"
399 - detailPrefix = " ├─"
400 - lastDetailPrefix = " └─"
401 - }
402 -
403 - fmt.Printf("│ %s context: %s, unit: %s, prio: %d, type: %s\n",
404 - contextPrefix, cs.context, cs.units, cs.priority, cs.chartType)
405 - fmt.Printf("│ %s title: %s\n", detailPrefix, cs.title)
406 -
407 - if len(cs.labelKeys) > 0 {
408 - fmt.Printf("│ %s labels: %s\n", detailPrefix, strings.Join(cs.labelKeys, ", "))
409 - } else {
410 - fmt.Printf("│ %s labels: (none)\n", detailPrefix)
411 - }
412 -
413 - fmt.Printf("│ %s dimensions: %s\n", detailPrefix, strings.Join(cs.dimNames, ", "))
414 - fmt.Printf("│ %s instances: %d, jobs: %d\n", lastDetailPrefix, cs.instances, len(cs.jobs))
415 - }
416 - }
417 -
418 - // Add a bottom border for the last family
419 - if len(families) > 0 {
420 - fmt.Println("└─────────────────────────────────────────────────────────────")
421 - }
422 -}
423 -
424 -func (da *DumpAnalyzer) writeJobMetadata(jobName, moduleName string) {
425 - if da.dataDir == "" {
426 - return
427 - }
428 - dir, ok := da.jobDirs[jobName]
429 - if !ok || dir == "" {
430 - return
431 - }
432 - meta := struct {
433 - Job string `json:"job"`
434 - Module string `json:"module"`
435 - Created time.Time `json:"created_at"`
436 - Metadata map[string]string `json:"metadata"`
437 - }{
438 - Job: jobName,
439 - Module: moduleName,
440 - Created: time.Now(),
441 - Metadata: map[string]string{
442 - "module": moduleName,
443 - },
444 - }
445 - path := filepath.Join(dir, "meta", "job.json")
446 - _ = writeJSON(path, meta)
447 -}
448 -
449 -func (da *DumpAnalyzer) writeMetrics(jobName string, seq int, mx map[string]int64) {
450 - if da.dataDir == "" {
451 - return
452 - }
453 - dir, ok := da.jobDirs[jobName]
454 - if !ok || dir == "" {
455 - return
456 - }
457 - metricsDir := filepath.Join(dir, "metrics")
458 - _ = os.MkdirAll(metricsDir, 0o755)
459 - payload := struct {
460 - CollectedAt time.Time `json:"collected_at"`
461 - Metrics map[string]int64 `json:"metrics"`
462 - }{
463 - CollectedAt: time.Now(),
464 - Metrics: mx,
465 - }
466 - filename := fmt.Sprintf("metrics-%04d.json", seq)
467 - path := filepath.Join(metricsDir, filename)
468 - _ = writeJSON(path, payload)
469 -}
470 -
471 -func (da *DumpAnalyzer) markJobCollected(jobName string) {
472 - if da.dataDir == "" {
473 - return
474 - }
475 - if da.jobDone == nil {
476 - return
477 - }
478 - da.jobDone[jobName] = true
479 - for job, dir := range da.jobDirs {
480 - if dir == "" {
481 - continue
482 - }
483 - if !da.jobDone[job] {
484 - return
485 - }
486 - }
487 - if da.completed {
488 - return
489 - }
490 - da.completed = true
491 - da.writeManifest()
492 - if da.onComplete != nil {
493 - go da.onComplete()
494 - }
495 -}
496 -
497 -func (da *DumpAnalyzer) writeManifest() {
498 - if da.dataDir == "" {
499 - return
500 - }
501 - type manifestJob struct {
502 - Name string `json:"name"`
503 - Module string `json:"module"`
504 - Directory string `json:"directory"`
505 - Collections int `json:"collections"`
506 - }
507 - var jobs []manifestJob
508 - for name, job := range da.jobs {
509 - dir := da.jobDirs[name]
510 - jobs = append(jobs, manifestJob{
511 - Name: name,
512 - Module: job.Module,
513 - Directory: dir,
514 - Collections: job.CollectionCount,
515 - })
516 - }
517 - sort.Slice(jobs, func(i, j int) bool { return jobs[i].Name < jobs[j].Name })
518 - manifest := struct {
519 - GeneratedAt time.Time `json:"generated_at"`
520 - Jobs []manifestJob `json:"jobs"`
521 - }{
522 - GeneratedAt: time.Now(),
523 - Jobs: jobs,
524 - }
525 - _ = writeJSON(filepath.Join(da.dataDir, "manifest.json"), manifest)
526 -}
527 -
528 -func writeJSON(path string, payload any) error {
529 - data, err := json.MarshalIndent(payload, "", " ")
530 - if err != nil {
531 - return err
532 - }
533 - return os.WriteFile(path, data, 0o644)
534 -}
535 -
536 -// contextInfo holds information about a context within a family
537 -type contextInfo struct {
538 - family string
539 - context string
540 - charts []*ChartAnalysis
541 - minPriority int
542 -}
543 -
544 -func (da *DumpAnalyzer) printJobAnalysis(job *JobAnalysis) {
545 - // First, check for duplicate chart IDs (SEVERE BUG)
546 - chartIDCounts := make(map[string]int)
547 - for i := range job.Charts {
548 - ca := &job.Charts[i]
549 - chartIDCounts[ca.Chart.ID]++
550 - }
551 -
552 - // Check for contexts appearing in multiple families (SEVERE BUG)
553 - contextToFamilies := make(map[string][]string)
554 -
555 - families := make(map[string]map[string]*contextInfo) // family -> context -> info
556 - familyMinPriority := make(map[string]int)
557 -
558 - // Track issues for summary
559 - contextIssues := make(map[string][]string)
560 -
561 - // Group charts and check for duplicate contexts
562 - for i := range job.Charts {
563 - ca := &job.Charts[i]
564 - family := ca.Chart.Fam
565 - if family == "" {
566 - family = "(no family)"
567 - }
568 - ctx := ca.Chart.Ctx
569 -
570 - // Track context to families mapping
571 - if _, exists := contextToFamilies[ctx]; !exists {
572 - contextToFamilies[ctx] = []string{}
573 - }
574 - if !contains(contextToFamilies[ctx], family) {
575 - contextToFamilies[ctx] = append(contextToFamilies[ctx], family)
576 - }
577 -
578 - // Initialize family if needed
579 - if _, exists := families[family]; !exists {
580 - families[family] = make(map[string]*contextInfo)
581 - familyMinPriority[family] = ca.Chart.Priority
582 - }
583 -
584 - // Update family minimum priority
585 - if ca.Chart.Priority < familyMinPriority[family] {
586 - familyMinPriority[family] = ca.Chart.Priority
587 - }
588 -
589 - // Initialize context if needed
590 - if _, exists := families[family][ctx]; !exists {
591 - families[family][ctx] = &contextInfo{
592 - family: family,
593 - context: ctx,
594 - charts: []*ChartAnalysis{},
595 - minPriority: ca.Chart.Priority,
596 - }
597 - }
598 -
599 - // Update context minimum priority
600 - if ca.Chart.Priority < families[family][ctx].minPriority {
601 - families[family][ctx].minPriority = ca.Chart.Priority
602 - }
603 -
604 - families[family][ctx].charts = append(families[family][ctx].charts, ca)
605 - }
606 -
607 - // Check for severe bugs - duplicate chart IDs and contexts in multiple families
608 - fmt.Println("\n" + job.Name)
609 -
610 - // Report duplicate chart IDs first (most severe)
611 - for chartID, count := range chartIDCounts {
612 - if count > 1 {
613 - fmt.Printf("🔴 SEVERE BUG: Chart ID '%s' defined %d times - this causes data corruption!\n",
614 - chartID, count)
615 - contextIssues[chartID] = append(contextIssues[chartID],
616 - fmt.Sprintf("SEVERE BUG - chart ID defined %d times (data corruption)", count))
617 - }
618 - }
619 -
620 - // Report contexts in multiple families
621 - for ctx, fams := range contextToFamilies {
622 - if len(fams) > 1 {
623 - fmt.Printf("🔴 SEVERE BUG: Context '%s' appears in multiple families: %s\n",
624 - ctx, strings.Join(fams, ", "))
625 - contextIssues[ctx] = append(contextIssues[ctx],
626 - fmt.Sprintf("SEVERE BUG - appears in multiple families: %s", strings.Join(fams, ", ")))
627 - }
628 - }
629 -
630 - // Check for duplicate dimension IDs across ALL charts (SEVERE BUG)
631 - allDimIDs := make(map[string][]string) // dimID -> []chartIDs
632 - for i := range job.Charts {
633 - ca := &job.Charts[i]
634 - for _, dim := range ca.Chart.Dims {
635 - if _, exists := allDimIDs[dim.ID]; !exists {
636 - allDimIDs[dim.ID] = []string{}
637 - }
638 - allDimIDs[dim.ID] = append(allDimIDs[dim.ID], ca.Chart.ID)
639 - }
640 - }
641 -
642 - // Report duplicate dimension IDs
643 - for dimID, chartIDs := range allDimIDs {
644 - if len(chartIDs) > 1 {
645 - fmt.Printf("🔴 SEVERE BUG: Dimension ID '%s' is used in %d charts: %s\n",
646 - dimID, len(chartIDs), strings.Join(chartIDs, ", "))
647 - // Add to issues for each affected context
648 - for _, chartID := range chartIDs {
649 - // Find the context for this chart
650 - for i := range job.Charts {
651 - if job.Charts[i].Chart.ID == chartID {
652 - ctx := job.Charts[i].Chart.Ctx
653 - contextIssues[ctx] = append(contextIssues[ctx],
654 - fmt.Sprintf("SEVERE BUG - dimension ID '%s' is shared with charts: %s", dimID, strings.Join(chartIDs, ", ")))
655 - break
656 - }
657 - }
658 - }
659 - }
660 - }
661 -
662 - // Proper excess metrics analysis
663 - da.analyzeMetricDimensionMatching(job, allDimIDs, contextIssues)
664 -
665 - // Family structure analysis
666 - da.analyzeFamilyStructureForJob(job, contextIssues)
667 -
668 - // Sort families by minimum priority
669 - var sortedFamilies []string
670 - for fam := range families {
671 - sortedFamilies = append(sortedFamilies, fam)
672 - }
673 - sort.Slice(sortedFamilies, func(i, j int) bool {
674 - return familyMinPriority[sortedFamilies[i]] < familyMinPriority[sortedFamilies[j]]
675 - })
676 -
677 - // Print analysis for each family
678 - for _, family := range sortedFamilies {
679 - fmt.Printf("\n├─ family= %s\n", family)
680 -
681 - // Sort contexts by minimum priority
682 - var sortedContexts []string
683 - for ctx := range families[family] {
684 - sortedContexts = append(sortedContexts, ctx)
685 - }
686 - sort.Slice(sortedContexts, func(i, j int) bool {
687 - return families[family][sortedContexts[i]].minPriority <
688 - families[family][sortedContexts[j]].minPriority
689 - })
690 -
691 - for i, ctx := range sortedContexts {
692 - isLast := i == len(sortedContexts)-1
693 - ctxInfo := families[family][ctx]
694 - issues := da.printContextAnalysis(ctxInfo, isLast)
695 - if len(issues) > 0 {
696 - contextIssues[ctx] = append(contextIssues[ctx], issues...)
697 - }
698 - }
699 - }
700 -
701 - // Print greppable summary
702 - fmt.Println("\n" + strings.Repeat("═", 80))
703 - fmt.Println("ISSUE SUMMARY (greppable)")
704 - fmt.Println(strings.Repeat("═", 80))
705 -
706 - errorCount := 0
707 - warningCount := 0
708 - infoCount := 0
709 - for ctx, issues := range contextIssues {
710 - if len(issues) > 0 {
711 - for _, issue := range issues {
712 - emoji := "❌"
713 - if strings.HasPrefix(issue, "INFO:") {
714 - emoji = "ℹ️"
715 - infoCount++
716 - } else if strings.Contains(issue, "WARNING") {
717 - emoji = "🟡"
718 - warningCount++
719 - } else if strings.Contains(issue, "SEVERE BUG") {
720 - emoji = "🔴"
721 - errorCount++
722 - } else {
723 - errorCount++
724 - }
725 - fmt.Printf("%s IDENTIFIED ISSUES ON %s: %s\n", emoji, ctx, issue)
726 - }
727 - }
728 - }
729 -
730 - issueCount := errorCount // Only count real errors for final status
731 -
732 - // Calculate statistics for the summary independently to avoid interfering with tree logic
733 - statsFamilies := make(map[string]bool)
734 - statsContexts := make(map[string]bool)
735 - statsInstances := 0
736 - statsTimeSeries := 0
737 - statsCollectedValues := 0
738 -
739 - // Calculate distinct {context}.{dimension} combinations
740 - uniqueContextDimensions := make(map[string]bool)
741 -
742 - for i := range job.Charts {
743 - ca := &job.Charts[i]
744 - statsInstances++
745 -
746 - // Track unique families and contexts for stats
747 - family := ca.Chart.Fam
748 - if family == "" {
749 - family = "(no family)"
750 - }
751 - statsFamilies[family] = true
752 - statsContexts[ca.Chart.Ctx] = true
753 -
754 - // Count dimensions (time-series) and track distinct {context}.{dimension} combinations
755 - statsTimeSeries += len(ca.Chart.Dims)
756 - for _, dim := range ca.Chart.Dims {
757 - statsCollectedValues += len(ca.CollectedValues[dim.ID])
758 - // Use dimension name for display, fall back to ID if name is empty
759 - dimName := dim.Name
760 - if dimName == "" {
761 - dimName = dim.ID
762 - }
763 - // Create unique key as context.dimension
764 - uniqueKey := fmt.Sprintf("%s.%s", ca.Chart.Ctx, dimName)
765 - uniqueContextDimensions[uniqueKey] = true
766 - }
767 - }
768 -
769 - // Count total distinct {context}.{dimension} combinations
770 - statsDistinctDimensions := len(uniqueContextDimensions)
771 -
772 - // Count unique metrics in mx map
773 - uniqueMetricsInMx := len(job.AllSeenMetrics)
774 -
775 - // Generate summary with detailed stats
776 - warningText := ""
777 - if warningCount > 0 {
778 - warningText = fmt.Sprintf(", %d warnings", warningCount)
779 - }
780 -
781 - if issueCount == 0 && statsTimeSeries == uniqueMetricsInMx {
782 - if warningCount > 0 {
783 - 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",
784 - warningText, job.Name, len(statsFamilies), len(statsContexts), statsDistinctDimensions, statsInstances, statsTimeSeries, uniqueMetricsInMx)
785 - } else {
786 - fmt.Printf("🟢 NO ISSUES FOUND, job %s defines: %d families, %d contexts, %d dimensions, %d instances, %d time-series, collects: %d unique metrics\n",
787 - job.Name, len(statsFamilies), len(statsContexts), statsDistinctDimensions, statsInstances, statsTimeSeries, uniqueMetricsInMx)
788 - }
789 - } else if issueCount == 0 && statsTimeSeries != uniqueMetricsInMx {
790 - // Mismatch between time-series and unique metrics even though no specific issues found
791 - fmt.Printf("🟡 DIMENSION MISMATCH%s, job %s defines: %d families, %d contexts, %d dimensions, %d instances, %d time-series, collects: %d unique metrics\n",
792 - warningText, job.Name, len(statsFamilies), len(statsContexts), statsDistinctDimensions, statsInstances, statsTimeSeries, uniqueMetricsInMx)
793 - } else {
794 - fmt.Printf("🔴 ISSUES FOUND%s, job %s defines: %d families, %d contexts, %d dimensions, %d instances, %d time-series, collects: %d unique metrics\n",
795 - warningText, job.Name, len(statsFamilies), len(statsContexts), statsDistinctDimensions, statsInstances, statsTimeSeries, uniqueMetricsInMx)
796 - }
797 -}
798 -
799 -func (da *DumpAnalyzer) printContextAnalysis(ctxInfo *contextInfo, isLast bool) []string {
800 - charts := ctxInfo.charts
801 - var issues []string
802 -
803 - // Analyze titles
804 - titles := make(map[string]int)
805 - for _, ca := range charts {
806 - titles[ca.Chart.Title]++
807 - }
808 -
809 - // Analyze units
810 - units := make(map[string]int)
811 - for _, ca := range charts {
812 - units[ca.Chart.Units]++
813 - }
814 -
815 - // Analyze priorities
816 - priorities := make(map[int]int)
817 - for _, ca := range charts {
818 - priorities[ca.Chart.Priority]++
819 - }
820 -
821 - // Analyze label keys
822 - labelKeysByChart := make(map[string]map[string]bool) // chartID -> set of keys
823 - allLabelKeys := make(map[string]bool)
824 - for _, ca := range charts {
825 - labelKeysByChart[ca.Chart.ID] = make(map[string]bool)
826 - for _, label := range ca.Chart.Labels {
827 - labelKeysByChart[ca.Chart.ID][label.Key] = true
828 - allLabelKeys[label.Key] = true
829 - }
830 - }
831 -
832 - // Analyze dimensions
833 - dimsByChart := make(map[string]map[string]*collectorapi.Dim) // chartID -> dimID -> dim
834 - allDimIDs := make(map[string]bool)
835 - for _, ca := range charts {
836 - dimsByChart[ca.Chart.ID] = make(map[string]*collectorapi.Dim)
837 - for _, dim := range ca.Chart.Dims {
838 - dimsByChart[ca.Chart.ID][dim.ID] = dim
839 - allDimIDs[dim.ID] = true
840 - }
841 - }
842 -
843 - // Tree prefixes
844 - ctxPrefix := "├─"
845 - treePrefix := "│ "
846 - if isLast {
847 - ctxPrefix = "└─"
848 - treePrefix = " "
849 - }
850 -
851 - // Print context header
852 - fmt.Printf("%s ⚡ context= %s\n", ctxPrefix, ctxInfo.context)
853 -
854 - // Print titles
855 - if len(titles) == 1 {
856 - for title := range titles {
857 - fmt.Printf("%s ├─ title= %s ✅\n", treePrefix, title)
858 - }
859 - } else {
860 - fmt.Printf("%s ├─ title= ❌ INCONSISTENT (%d different titles)\n", treePrefix, len(titles))
861 - for title, count := range titles {
862 - fmt.Printf("%s │ ├─ %s (in %d charts)\n", treePrefix, title, count)
863 - }
864 - issues = append(issues, fmt.Sprintf("inconsistent titles (%d different)", len(titles)))
865 - }
866 -
867 - // Print units, priority, and chart type on one line
868 - unitsStr := ""
869 - unitsEmoji := " ✅"
870 - if len(units) == 1 {
871 - for unit := range units {
872 - unitsStr = unit
873 - }
874 - } else {
875 - unitsStr = fmt.Sprintf("INCONSISTENT (%d different)", len(units))
876 - unitsEmoji = " ❌"
877 - issues = append(issues, fmt.Sprintf("inconsistent units (%d different)", len(units)))
878 - }
879 -
880 - priorityStr := ""
881 - priorityEmoji := " ✅"
882 - if len(priorities) == 1 {
883 - for priority := range priorities {
884 - priorityStr = fmt.Sprintf("%d", priority)
885 - }
886 - } else {
887 - priorityStr = fmt.Sprintf("%d (INCONSISTENT: %d different)", ctxInfo.minPriority, len(priorities))
888 - priorityEmoji = " 🟡"
889 - issues = append(issues, fmt.Sprintf("inconsistent priorities (%d different)", len(priorities)))
890 - }
891 -
892 - // Collect chart types
893 - chartTypes := make(map[string]int)
894 - for _, ca := range charts {
895 - chartTypes[ca.Chart.Type.String()]++
896 - }
897 -
898 - typeStr := ""
899 - typeEmoji := " ✅"
900 - if len(chartTypes) == 1 {
901 - for typ := range chartTypes {
902 - typeStr = typ
903 - }
904 - } else {
905 - typeStr = fmt.Sprintf("INCONSISTENT (%d different)", len(chartTypes))
906 - typeEmoji = " ❌"
907 - issues = append(issues, fmt.Sprintf("inconsistent chart types (%d different)", len(chartTypes)))
908 - }
909 -
910 - fmt.Printf("%s ├─ units= %s%s, priority= %s%s, type= %s%s\n",
911 - treePrefix, unitsStr, unitsEmoji, priorityStr, priorityEmoji, typeStr, typeEmoji)
912 -
913 - // Print label keys
914 - fmt.Printf("%s ├─ label keys= ", treePrefix)
915 - if len(allLabelKeys) == 0 {
916 - fmt.Printf("(none) ✅\n")
917 - } else {
918 - var labelKeyList []string
919 - for key := range allLabelKeys {
920 - labelKeyList = append(labelKeyList, key)
921 - }
922 - sort.Strings(labelKeyList)
923 -
924 - var labelKeyStatus []string
925 - hasInconsistentLabels := false
926 - for _, key := range labelKeyList {
927 - allHaveIt := true
928 - for chartID := range labelKeysByChart {
929 - if !labelKeysByChart[chartID][key] {
930 - allHaveIt = false
931 - hasInconsistentLabels = true
932 - break
933 - }
934 - }
935 - if allHaveIt {
936 - labelKeyStatus = append(labelKeyStatus, fmt.Sprintf("%s✅", key))
937 - } else {
938 - labelKeyStatus = append(labelKeyStatus, fmt.Sprintf("%s❌", key))
939 - }
940 - }
941 - fmt.Printf("%s", strings.Join(labelKeyStatus, ", "))
942 - if hasInconsistentLabels {
943 - fmt.Printf(" 🟡 SOME MISSING")
944 - issues = append(issues, "WARNING - inconsistent label keys (natural for heterogeneous instances)")
945 - }
946 - fmt.Printf("\n")
947 - }
948 -
949 - // Collect all dimension names across all charts
950 - dimNamesByChart := make(map[string]map[string]string) // chartID -> dimName -> dimID
951 - allDimNames := make(map[string]bool)
952 -
953 - for _, ca := range charts {
954 - dimNamesByChart[ca.Chart.ID] = make(map[string]string)
955 - for _, dim := range ca.Chart.Dims {
956 - name := dim.Name
957 - if name == "" {
958 - name = dim.ID
959 - }
960 - dimNamesByChart[ca.Chart.ID][name] = dim.ID
961 - allDimNames[name] = true
962 - }
963 - }
964 -
965 - // Print dimensions (names only at context level)
966 - fmt.Printf("%s ├─ dimensions=\n", treePrefix)
967 - var dimNameList []string
968 - for dimName := range allDimNames {
969 - dimNameList = append(dimNameList, dimName)
970 - }
971 - sort.Strings(dimNameList)
972 -
973 - // Check multipliers, dividers, and algorithms consistency across all charts for each dimension
974 - dimMultDivInfo := make(map[string]map[string][]int) // dimName -> "mul"/"div" -> []values
975 - dimAlgoInfo := make(map[string][]string) // dimName -> []algorithms
976 - contextAlgorithms := make(map[string]bool) // track all algorithms used in this context
977 -
978 - for dimName := range allDimNames {
979 - dimMultDivInfo[dimName] = map[string][]int{
980 - "mul": {},
981 - "div": {},
982 - }
983 - dimAlgoInfo[dimName] = []string{}
984 -
985 - // Collect all multipliers, dividers, and algorithms for this dimension name across charts
986 - for _, ca := range charts {
987 - for _, dim := range ca.Chart.Dims {
988 - name := dim.Name
989 - if name == "" {
990 - name = dim.ID
991 - }
992 - if name == dimName {
993 - // Treat 0 as 1 (default value)
994 - mul := dim.Mul
995 - if mul == 0 {
996 - mul = 1
997 - }
998 - div := dim.Div
999 - if div == 0 {
1000 - div = 1
1001 - }
1002 - dimMultDivInfo[dimName]["mul"] = append(dimMultDivInfo[dimName]["mul"], mul)
1003 - dimMultDivInfo[dimName]["div"] = append(dimMultDivInfo[dimName]["div"], div)
1004 -
1005 - // Collect algorithm
1006 - algo := dim.Algo.String()
1007 - dimAlgoInfo[dimName] = append(dimAlgoInfo[dimName], algo)
1008 - contextAlgorithms[algo] = true
1009 - }
1010 - }
1011 - }
1012 - }
1013 -
1014 - // Check for mixed algorithms in the context
1015 - if len(contextAlgorithms) > 1 {
1016 - algoList := []string{}
1017 - for algo := range contextAlgorithms {
1018 - algoList = append(algoList, algo)
1019 - }
1020 - sort.Strings(algoList)
1021 - issues = append(issues, fmt.Sprintf("mixed dimension algorithms (%s)", strings.Join(algoList, ", ")))
1022 - }
1023 -
1024 - // Check for rate units with absolute algorithm
1025 - if len(units) == 1 && len(contextAlgorithms) == 1 {
1026 - for unit := range units {
1027 - for algo := range contextAlgorithms {
1028 - // Check if unit contains rate indicator (per second, per minute, etc.)
1029 - if strings.Contains(unit, "/") && algo == "absolute" {
1030 - issues = append(issues, fmt.Sprintf("WARNING - rate unit '%s' with absolute algorithm (should use incremental)", unit))
1031 - }
1032 - }
1033 - }
1034 - }
1035 -
1036 - // Check for generic units that indicate mixed metric types
1037 - if len(units) == 1 {
1038 - for unit := range units {
1039 - lowerUnit := strings.ToLower(unit)
1040 - // Check for generic counting units
1041 - if lowerUnit == "value" || lowerUnit == "values" ||
1042 - lowerUnit == "count" || lowerUnit == "counts" ||
1043 - lowerUnit == "number" || lowerUnit == "numbers" ||
1044 - lowerUnit == "amount" || lowerUnit == "amounts" ||
1045 - lowerUnit == "quantity" || lowerUnit == "quantities" {
1046 - issues = append(issues, fmt.Sprintf("WARNING - generic unit '%s' suggests mixed metric types (apples and oranges)", unit))
1047 - }
1048 - }
1049 - }
1050 -
1051 - hasMissingDims := false
1052 - hasMultDivInconsistency := false
1053 - for i, dimName := range dimNameList {
1054 - // Check if all charts have this dimension name
1055 - allHaveIt := true
1056 - for chartID := range dimNamesByChart {
1057 - if _, exists := dimNamesByChart[chartID][dimName]; !exists {
1058 - allHaveIt = false
1059 - hasMissingDims = true
1060 - break
1061 - }
1062 - }
1063 -
1064 - prefix := "├─"
1065 - if i == len(dimNameList)-1 {
1066 - prefix = "└─"
1067 - }
1068 -
1069 - dimStatus := ""
1070 - if !allHaveIt {
1071 - dimStatus = " 🟡 NOT IN ALL CHARTS"
1072 - }
1073 -
1074 - // Check multiplier/divider consistency
1075 - mulValues := dimMultDivInfo[dimName]["mul"]
1076 - divValues := dimMultDivInfo[dimName]["div"]
1077 - algoValues := dimAlgoInfo[dimName]
1078 -
1079 - // Get unique multipliers, dividers, and algorithms
1080 - uniqueMuls := make(map[int]bool)
1081 - uniqueDivs := make(map[int]bool)
1082 - uniqueAlgos := make(map[string]bool)
1083 - for _, m := range mulValues {
1084 - uniqueMuls[m] = true
1085 - }
1086 - for _, d := range divValues {
1087 - uniqueDivs[d] = true
1088 - }
1089 - for _, a := range algoValues {
1090 - uniqueAlgos[a] = true
1091 - }
1092 -
1093 - // Format multiplier/divider/algorithm info
1094 - multDivAlgoStr := ""
1095 - multDivAlgoEmoji := " ✅"
1096 -
1097 - // Check consistency
1098 - if len(uniqueMuls) > 1 || len(uniqueDivs) > 1 || len(uniqueAlgos) > 1 {
1099 - hasMultDivInconsistency = true
1100 - multDivAlgoEmoji = " ❌"
1101 - }
1102 -
1103 - // Format the multiplier/divider/algorithm string - ALWAYS show them
1104 - if len(uniqueMuls) == 1 && len(uniqueDivs) == 1 && len(uniqueAlgos) == 1 {
1105 - var mul, div int
1106 - var algo string
1107 - for m := range uniqueMuls {
1108 - mul = m
1109 - }
1110 - for d := range uniqueDivs {
1111 - div = d
1112 - }
1113 - for a := range uniqueAlgos {
1114 - algo = a
1115 - }
1116 -
1117 - // Always show multiplier, divider, and algorithm
1118 - multDivAlgoStr = fmt.Sprintf(" ×%d ÷%d %s", mul, div, algo)
1119 - } else {
1120 - // Show all variations if inconsistent
1121 - parts := []string{}
1122 -
1123 - if len(uniqueMuls) == 1 {
1124 - var mul int
1125 - for m := range uniqueMuls {
1126 - mul = m
1127 - }
1128 - parts = append(parts, fmt.Sprintf("×%d", mul))
1129 - } else {
1130 - mulStrs := []string{}
1131 - for m := range uniqueMuls {
1132 - mulStrs = append(mulStrs, fmt.Sprintf("%d", m))
1133 - }
1134 - parts = append(parts, fmt.Sprintf("×(%s)", strings.Join(mulStrs, ",")))
1135 - }
1136 -
1137 - if len(uniqueDivs) == 1 {
1138 - var div int
1139 - for d := range uniqueDivs {
1140 - div = d
1141 - }
1142 - parts = append(parts, fmt.Sprintf("÷%d", div))
1143 - } else {
1144 - divStrs := []string{}
1145 - for d := range uniqueDivs {
1146 - divStrs = append(divStrs, fmt.Sprintf("%d", d))
1147 - }
1148 - parts = append(parts, fmt.Sprintf("÷(%s)", strings.Join(divStrs, ",")))
1149 - }
1150 -
1151 - if len(uniqueAlgos) == 1 {
1152 - var algo string
1153 - for a := range uniqueAlgos {
1154 - algo = a
1155 - }
1156 - parts = append(parts, algo)
1157 - } else {
1158 - algoStrs := []string{}
1159 - for a := range uniqueAlgos {
1160 - algoStrs = append(algoStrs, a)
1161 - }
1162 - sort.Strings(algoStrs)
1163 - parts = append(parts, fmt.Sprintf("(%s)", strings.Join(algoStrs, ",")))
1164 - }
1165 -
1166 - multDivAlgoStr = fmt.Sprintf(" %s", strings.Join(parts, " "))
1167 - }
1168 -
1169 - fmt.Printf("%s │ %s %s%s%s%s\n", treePrefix, prefix, dimName, multDivAlgoStr, multDivAlgoEmoji, dimStatus)
1170 - }
1171 -
1172 - if hasMissingDims {
1173 - issues = append(issues, "WARNING - missing dimensions in some charts (natural for heterogeneous instances)")
1174 - }
1175 -
1176 - if hasMultDivInconsistency {
1177 - // Add detailed multiplier/divider inconsistency issues
1178 - for dimName, info := range dimMultDivInfo {
1179 - mulValues := info["mul"]
1180 - divValues := info["div"]
1181 -
1182 - uniqueMuls := make(map[int]int)
1183 - uniqueDivs := make(map[int]int)
1184 - for _, m := range mulValues {
1185 - uniqueMuls[m]++
1186 - }
1187 - for _, d := range divValues {
1188 - uniqueDivs[d]++
1189 - }
1190 -
1191 - if len(uniqueMuls) > 1 {
1192 - mulStrs := []string{}
1193 - for m, count := range uniqueMuls {
1194 - mulStrs = append(mulStrs, fmt.Sprintf("%d (in %d charts)", m, count))
1195 - }
1196 - issues = append(issues, fmt.Sprintf("dimension '%s' has inconsistent multipliers: %s", dimName, strings.Join(mulStrs, ", ")))
1197 - }
1198 -
1199 - if len(uniqueDivs) > 1 {
1200 - divStrs := []string{}
1201 - for d, count := range uniqueDivs {
1202 - divStrs = append(divStrs, fmt.Sprintf("%d (in %d charts)", d, count))
1203 - }
1204 - issues = append(issues, fmt.Sprintf("dimension '%s' has inconsistent dividers: %s", dimName, strings.Join(divStrs, ", ")))
1205 - }
1206 - }
1207 - }
1208 -
1209 - // Check if any dimensions are missing data across all instances
1210 - missingDataDetails := []string{}
1211 - for _, ca := range charts {
1212 - for _, dim := range ca.Chart.Dims {
1213 - if !ca.SeenDimensions[dim.ID] || len(ca.CollectedValues[dim.ID]) == 0 {
1214 - dimName := dim.Name
1215 - if dimName == "" {
1216 - dimName = dim.ID
1217 - }
1218 - // Show both ID and name for clarity
1219 - dimInfo := fmt.Sprintf("'%s'", dim.ID)
1220 - if dim.Name != "" && dim.Name != dim.ID {
1221 - dimInfo = fmt.Sprintf("'%s' ('%s')", dim.ID, dim.Name)
1222 - }
1223 - missingDataDetails = append(missingDataDetails, fmt.Sprintf("dimension %s on chart '%s' is not collected", dimInfo, ca.Chart.ID))
1224 - }
1225 - }
1226 - }
1227 -
1228 - // Add all missing data issues
1229 - issues = append(issues, missingDataDetails...)
1230 -
1231 - // Print instances
1232 - fmt.Printf("%s └─ instances=\n", treePrefix)
1233 - for i, ca := range charts {
1234 - labelPairs := []string{}
1235 - for _, label := range ca.Chart.Labels {
1236 - labelPairs = append(labelPairs, fmt.Sprintf("%s=%s", label.Key, label.Value))
1237 - }
1238 - labelStr := ""
1239 - if len(labelPairs) > 0 {
1240 - labelStr = fmt.Sprintf(" {%s}", strings.Join(labelPairs, ", "))
1241 - }
1242 -
1243 - // Extract name from ID if possible
1244 - name := ""
1245 - if ca.Chart.OverID != "" {
1246 - name = ca.Chart.OverID
1247 - }
1248 -
1249 - instPrefix := "├─"
1250 - instTreePrefix := "│ "
1251 - if i == len(charts)-1 {
1252 - instPrefix = "└─"
1253 - instTreePrefix = " "
1254 - }
1255 -
1256 - fmt.Printf("%s %s %s (%s)%s\n", treePrefix, instPrefix, ca.Chart.ID, name, labelStr)
1257 -
1258 - // Print dimension status for this instance
1259 - for _, dim := range ca.Chart.Dims {
1260 - dimName := dim.Name
1261 - if dimName == "" {
1262 - dimName = dim.ID
1263 - }
1264 -
1265 - emoji := "❌"
1266 - valueStr := ""
1267 - if ca.SeenDimensions[dim.ID] && len(ca.CollectedValues[dim.ID]) > 0 {
1268 - emoji = "✅"
1269 -
1270 - // Format sample values
1271 - values := ca.CollectedValues[dim.ID]
1272 - if len(values) > 5 {
1273 - // Show first 3 and last 2 values for long series
1274 - firstVals := []string{}
1275 - for i := 0; i < 3; i++ {
1276 - firstVals = append(firstVals, fmt.Sprintf("%d", values[i]))
1277 - }
1278 - lastVals := []string{}
1279 - for i := len(values) - 2; i < len(values); i++ {
1280 - lastVals = append(lastVals, fmt.Sprintf("%d", values[i]))
1281 - }
1282 - valueStr = fmt.Sprintf(": [%s, ..., %s] ", strings.Join(firstVals, ", "), strings.Join(lastVals, ", "))
1283 - } else {
1284 - // Show all values for short series
1285 - valStrs := []string{}
1286 - for _, v := range values {
1287 - valStrs = append(valStrs, fmt.Sprintf("%d", v))
1288 - }
1289 - valueStr = fmt.Sprintf(": [%s] ", strings.Join(valStrs, ", "))
1290 - }
1291 - }
1292 -
1293 - // Format multiplier/divider and algorithm for this specific dimension
1294 - mul := dim.Mul
1295 - div := dim.Div
1296 - // Treat 0 as 1 (what the framework does)
1297 - if mul == 0 {
1298 - mul = 1
1299 - }
1300 - if div == 0 {
1301 - div = 1
1302 - }
1303 -
1304 - // Get algorithm
1305 - algo := string(dim.Algo)
1306 - if algo == "" {
1307 - algo = "absolute"
1308 - }
1309 -
1310 - // Always show multiplier, divider and algorithm
1311 - multDivAlgoStr := fmt.Sprintf(" ×%d ÷%d %s", mul, div, algo)
1312 -
1313 - fmt.Printf("%s %s %s %s%s%s %s\n", treePrefix, instTreePrefix, emoji, dimName, multDivAlgoStr, valueStr, dim.ID)
1314 - }
1315 -
1316 - }
1317 -
1318 - return issues
1319 -}
1320 -
1321 -func contains(slice []string, item string) bool {
1322 - for _, s := range slice {
1323 - if s == item {
1324 - return true
1325 - }
1326 - }
1327 - return false
1328 -}
1329 -
1330 -// analyzeMetricDimensionMatching performs comprehensive analysis of dimension/metric matching
1331 -func (da *DumpAnalyzer) analyzeMetricDimensionMatching(job *JobAnalysis, allDimIDs map[string][]string, contextIssues map[string][]string) {
1332 - // 1. Find duplicate dimension IDs across charts (already done above but let's be explicit)
1333 - duplicateDimensions := []string{}
1334 - for dimID, chartIDs := range allDimIDs {
1335 - if len(chartIDs) > 1 {
1336 - duplicateDimensions = append(duplicateDimensions, dimID)
1337 - // Find affected contexts
1338 - affectedContexts := make(map[string]bool)
1339 - for _, chartID := range chartIDs {
1340 - for i := range job.Charts {
1341 - if job.Charts[i].Chart.ID == chartID {
1342 - affectedContexts[job.Charts[i].Chart.Ctx] = true
1343 - break
1344 - }
1345 - }
1346 - }
1347 - for ctx := range affectedContexts {
1348 - contextIssues[ctx] = append(contextIssues[ctx],
1349 - fmt.Sprintf("SEVERE BUG - dimension '%s' is used in multiple charts: %s", dimID, strings.Join(chartIDs, ", ")))
1350 - }
1351 - }
1352 - }
1353 -
1354 - // 2. Get unique dimension IDs from charts
1355 - chartDimensions := make(map[string]bool)
1356 - for dimID := range allDimIDs {
1357 - chartDimensions[dimID] = true
1358 - }
1359 -
1360 - // 3. Get unique dimension IDs from values map (AllSeenMetrics)
1361 - valuesDimensions := make(map[string]bool)
1362 - for metricID := range job.AllSeenMetrics {
1363 - valuesDimensions[metricID] = true
1364 - }
1365 -
1366 - // 4. Find dimensions in charts but not in values (missing data)
1367 - missingValues := []string{}
1368 - for dimID := range chartDimensions {
1369 - if !valuesDimensions[dimID] {
1370 - missingValues = append(missingValues, dimID)
1371 - }
1372 - }
1373 -
1374 - // 5. Find dimensions in values but not in charts (excess metrics)
1375 - excessMetrics := []string{}
1376 - for metricID := range valuesDimensions {
1377 - if !chartDimensions[metricID] {
1378 - excessMetrics = append(excessMetrics, metricID)
1379 - }
1380 - }
1381 -
1382 - // Group missing values by context for reporting
1383 - if len(missingValues) > 0 {
1384 - contextMissingValues := make(map[string][]string)
1385 - for _, dimID := range missingValues {
1386 - // Find which context this dimension belongs to
1387 - for i := range job.Charts {
1388 - ca := &job.Charts[i]
1389 - for _, dim := range ca.Chart.Dims {
1390 - if dim.ID == dimID {
1391 - contextMissingValues[ca.Chart.Ctx] = append(contextMissingValues[ca.Chart.Ctx], dimID)
1392 - break
1393 - }
1394 - }
1395 - }
1396 - }
1397 -
1398 - for ctx, dims := range contextMissingValues {
1399 - sort.Strings(dims)
1400 - contextIssues[ctx] = append(contextIssues[ctx],
1401 - fmt.Sprintf("dimensions %s in charts do not have collected values", strings.Join(dims, ", ")))
1402 - }
1403 - }
1404 -
1405 - // Report excess metrics
1406 - if len(excessMetrics) > 0 {
1407 - sort.Strings(excessMetrics)
1408 - contextIssues["_general"] = append(contextIssues["_general"],
1409 - fmt.Sprintf("dimensions %s in the values map, do not exist in charts", strings.Join(excessMetrics, ", ")))
1410 - }
1411 -
1412 - // Print success messages with counts if no issues
1413 - if len(duplicateDimensions) == 0 {
1414 - fmt.Printf("✅ DIMENSION UNIQUENESS: All %d dimensions have unique IDs across charts\n", len(chartDimensions))
1415 - }
1416 -
1417 - if len(missingValues) == 0 && len(excessMetrics) == 0 {
1418 - fmt.Printf("✅ DIMENSION/VALUES MATCHING: %d chart dimensions perfectly match %d collected values\n",
1419 - len(chartDimensions), len(valuesDimensions))
1420 - } else {
1421 - if len(missingValues) > 0 {
1422 - fmt.Printf("❌ MISSING VALUES: %d chart dimensions have no collected values\n", len(missingValues))
1423 - }
1424 - if len(excessMetrics) > 0 {
1425 - fmt.Printf("❌ EXCESS VALUES: %d collected values have no corresponding chart dimensions\n", len(excessMetrics))
1426 - }
1427 - }
1428 -}
1429 -
1430 -// gcd calculates the greatest common divisor
1431 -func gcd(a, b int) int {
1432 - for b != 0 {
1433 - a, b = b, a%b
1434 - }
1435 - return a
1436 -}
1437 -
1438 -// analyzeFamilyStructureForJob performs family-level structural analysis for a single job
1439 -func (da *DumpAnalyzer) analyzeFamilyStructureForJob(job *JobAnalysis, contextIssues map[string][]string) {
1440 - // Get all charts from this job
1441 - allCharts := []*ChartAnalysis{}
1442 - for i := range job.Charts {
1443 - allCharts = append(allCharts, &job.Charts[i])
1444 - }
1445 -
1446 - // Group charts by family
1447 - type familyInfo struct {
1448 - contexts map[string][]*ChartAnalysis // context -> charts
1449 - labelPairs map[string]int // "key=value" -> count
1450 - hasSubfamilies bool
1451 - subfamilies map[string]bool
1452 - }
1453 -
1454 - families := make(map[string]*familyInfo) // family -> info
1455 - topLevelFamilies := make(map[string]bool)
1456 -
1457 - for _, ca := range allCharts {
1458 - family := ca.Chart.Fam
1459 - if family == "" {
1460 - family = "(no family)"
1461 - }
1462 -
1463 - // We'll check family depth later after all families are processed
1464 -
1465 - // Extract top-level family
1466 - topLevel := family
1467 - if idx := strings.Index(family, "/"); idx != -1 {
1468 - topLevel = family[:idx]
1469 - }
1470 - topLevelFamilies[topLevel] = true
1471 -
1472 - // Initialize family info
1473 - if _, exists := families[family]; !exists {
1474 - families[family] = &familyInfo{
1475 - contexts: make(map[string][]*ChartAnalysis),
1476 - labelPairs: make(map[string]int),
1477 - subfamilies: make(map[string]bool),
1478 - }
1479 - }
1480 -
1481 - // Track contexts
1482 - ctx := ca.Chart.Ctx
1483 - families[family].contexts[ctx] = append(families[family].contexts[ctx], ca)
1484 -
1485 - // Track label pairs
1486 - for _, label := range ca.Chart.Labels {
1487 - pair := fmt.Sprintf("%s=%s", label.Key, label.Value)
1488 - families[family].labelPairs[pair]++
1489 - }
1490 -
1491 - // Check for subfamilies
1492 - if strings.Contains(family, "/") {
1493 - parentFamily := family[:strings.Index(family, "/")]
1494 - if _, exists := families[parentFamily]; !exists {
1495 - families[parentFamily] = &familyInfo{
1496 - contexts: make(map[string][]*ChartAnalysis),
1497 - labelPairs: make(map[string]int),
1498 - subfamilies: make(map[string]bool),
1499 - }
1500 - }
1501 - families[parentFamily].hasSubfamilies = true
1502 - families[parentFamily].subfamilies[family] = true
1503 - }
1504 - }
1505 -
1506 - // Rule 0: Check family depth (deferred until all families are processed)
1507 - for family, info := range families {
1508 - slashCount := strings.Count(family, "/")
1509 - if slashCount > 2 {
1510 - // Add to the first context in this family
1511 - for ctx := range info.contexts {
1512 - contextIssues[ctx] = append(contextIssues[ctx],
1513 - 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))
1514 - break
1515 - }
1516 - }
1517 - }
1518 -
1519 - // Rule 1: Check label consistency within families
1520 - for family, info := range families {
1521 - if len(info.contexts) < 2 {
1522 - continue // Skip single-context families
1523 - }
1524 -
1525 - // Calculate total charts in this family
1526 - totalCharts := 0
1527 - for _, charts := range info.contexts {
1528 - totalCharts += len(charts)
1529 - }
1530 -
1531 - // Find inconsistent label pairs
1532 - inconsistentPairs := []string{}
1533 -
1534 - // The base unit is the number of contexts in the family
1535 - // Each label key-value pair should appear in multiples of this
1536 - baseUnit := len(info.contexts)
1537 -
1538 - // Check that each label pair count is a multiple of the base unit
1539 - for pair, actualCount := range info.labelPairs {
1540 - if baseUnit > 0 && actualCount%baseUnit != 0 {
1541 - inconsistentPairs = append(inconsistentPairs, fmt.Sprintf("'%s': %d", pair, actualCount))
1542 - }
1543 - }
1544 -
1545 - if len(inconsistentPairs) > 0 {
1546 - // Limit to first 10 pairs for readability
1547 - displayPairs := inconsistentPairs
1548 - if len(inconsistentPairs) > 10 {
1549 - displayPairs = inconsistentPairs[:10]
1550 - displayPairs = append(displayPairs, fmt.Sprintf("... and %d more", len(inconsistentPairs)-10))
1551 - }
1552 -
1553 - // Add to all contexts in this family
1554 - for ctx := range info.contexts {
1555 - contextIssues[ctx] = append(contextIssues[ctx],
1556 - 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",
1557 - family, baseUnit, strings.Join(displayPairs, ", ")))
1558 - }
1559 - }
1560 - }
1561 -
1562 - // Rule 2: Check same number of instances per context in a family
1563 - for family, info := range families {
1564 - if len(info.contexts) > 1 {
1565 - instanceCounts := make(map[int][]string)
1566 - for ctx, charts := range info.contexts {
1567 - count := len(charts)
1568 - instanceCounts[count] = append(instanceCounts[count], ctx)
1569 - }
1570 -
1571 - if len(instanceCounts) > 1 {
1572 - details := []string{}
1573 - for count, contexts := range instanceCounts {
1574 - details = append(details, fmt.Sprintf("%d instances: %s", count, strings.Join(contexts, ", ")))
1575 - }
1576 - // Add to all contexts in this family
1577 - for ctx := range info.contexts {
1578 - contextIssues[ctx] = append(contextIssues[ctx],
1579 - 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",
1580 - family, strings.Join(details, "; ")))
1581 - }
1582 - }
1583 - }
1584 - }
1585 -
1586 - // Rule 3: Check snake_case contexts
1587 - for _, ca := range allCharts {
1588 - ctx := ca.Chart.Ctx
1589 - if !isSnakeCase(ctx) {
1590 - contextIssues[ctx] = append(contextIssues[ctx],
1591 - 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))
1592 - }
1593 - }
1594 -
1595 - // Rule 4: Check families with >15 contexts
1596 - for family, info := range families {
1597 - if len(info.contexts) > 15 {
1598 - // Add to all contexts in this family
1599 - for ctx := range info.contexts {
1600 - contextIssues[ctx] = append(contextIssues[ctx],
1601 - 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",
1602 - family, len(info.contexts)))
1603 - }
1604 - }
1605 - }
1606 -
1607 - // Rule 5: Check generic family names
1608 - genericFamilies := map[string]bool{
1609 - "other": true,
1610 - "infrastructure": true,
1611 - "runtime": true,
1612 - }
1613 -
1614 - for family := range families {
1615 - // Check only the base family name (before /)
1616 - baseName := family
1617 - if idx := strings.Index(family, "/"); idx != -1 {
1618 - baseName = family[:idx]
1619 - }
1620 -
1621 - if genericFamilies[strings.ToLower(baseName)] {
1622 - // Add to all contexts in this family
1623 - for ctx := range families[family].contexts {
1624 - contextIssues[ctx] = append(contextIssues[ctx],
1625 - fmt.Sprintf("family '%s' uses generic name '%s'; possible cause: unclear categorization; possible fix: use specific names like 'database', 'webserver', 'messaging', etc.",
1626 - family, baseName))
1627 - }
1628 - }
1629 - }
1630 -
1631 - // Rule 6: Check families with both direct contexts and subfamilies
1632 - for family, info := range families {
1633 - if len(info.contexts) > 0 && info.hasSubfamilies {
1634 - // This is a parent family with both direct contexts and subfamilies
1635 - if !strings.Contains(family, "/") {
1636 - // Add to all contexts in this family
1637 - for ctx := range info.contexts {
1638 - contextIssues[ctx] = append(contextIssues[ctx],
1639 - fmt.Sprintf("family '%s' has both direct contexts and subfamilies; possible cause: mixed hierarchy; possible fix: move direct contexts to '%s/overview' or similar",
1640 - family, family))
1641 - }
1642 - }
1643 - }
1644 - }
1645 -
1646 - // Rule 7: Check top-level family count
1647 - if len(topLevelFamilies) > 15 {
1648 - familyList := []string{}
1649 - for f := range topLevelFamilies {
1650 - familyList = append(familyList, f)
1651 - }
1652 - sort.Strings(familyList)
1653 -
1654 - // Add to general issues (first context found)
1655 - for _, ca := range allCharts {
1656 - contextIssues[ca.Chart.Ctx] = append(contextIssues[ca.Chart.Ctx],
1657 - fmt.Sprintf("found %d top-level families (exceeds recommended 15): %s; possible cause: too many categories; possible fix: consolidate related families or use subfamilies",
1658 - len(topLevelFamilies), strings.Join(familyList, ", ")))
1659 - break // Only add once
1660 - }
1661 - }
1662 -
1663 - // Rule 8: Check subfamily counts
1664 - for family, info := range families {
1665 - if !strings.Contains(family, "/") && info.hasSubfamilies {
1666 - // This is a parent family, check its subfamilies
1667 - subfamilyCount := len(info.subfamilies)
1668 -
1669 - // Check for singleton subfamily without siblings
1670 - if subfamilyCount == 1 {
1671 - // Get the single subfamily name
1672 - var singleSubfamily string
1673 - for sf := range info.subfamilies {
1674 - singleSubfamily = sf
1675 - }
1676 - // Add to contexts in the parent family (if any) or the subfamily
1677 - if len(info.contexts) > 0 {
1678 - for ctx := range info.contexts {
1679 - contextIssues[ctx] = append(contextIssues[ctx],
1680 - fmt.Sprintf("family '%s' has only one subfamily '%s'; possible cause: incomplete hierarchy; possible fix: either add more subfamilies or flatten the structure",
1681 - family, singleSubfamily))
1682 - }
1683 - } else {
1684 - // Add to contexts in the single subfamily
1685 - if subfamilyInfo, exists := families[singleSubfamily]; exists {
1686 - for ctx := range subfamilyInfo.contexts {
1687 - contextIssues[ctx] = append(contextIssues[ctx],
1688 - fmt.Sprintf("family '%s' has only one subfamily '%s'; possible cause: incomplete hierarchy; possible fix: either add more subfamilies or flatten the structure",
1689 - family, singleSubfamily))
1690 - }
1691 - }
1692 - }
1693 - }
1694 -
1695 - // Check for too many subfamilies
1696 - if subfamilyCount > 8 {
1697 - // Add to contexts in the parent family (if any) or all subfamily contexts
1698 - if len(info.contexts) > 0 {
1699 - for ctx := range info.contexts {
1700 - contextIssues[ctx] = append(contextIssues[ctx],
1701 - 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",
1702 - family, subfamilyCount))
1703 - }
1704 - } else {
1705 - // Add to one context from each subfamily
1706 - for subfamily := range info.subfamilies {
1707 - if subfamilyInfo, exists := families[subfamily]; exists {
1708 - for ctx := range subfamilyInfo.contexts {
1709 - contextIssues[ctx] = append(contextIssues[ctx],
1710 - 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",
1711 - family, subfamilyCount))
1712 - break // Only add to one context per subfamily
1713 - }
1714 - }
1715 - }
1716 - }
1717 - }
1718 - }
1719 - }
1720 -}
1721 -
1722 -// isSnakeCase checks if a string is in snake_case format
1723 -func isSnakeCase(s string) bool {
1724 - // Should be lowercase with underscores, dots allowed for contexts
1725 - for _, ch := range s {
1726 - if !((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '.') {
1727 - return false
1728 - }
1729 - }
1730 - return true
1731 -}
1732 -
1733 -// PrintDebugInfo prints additional debug information
1734 -func (da *DumpAnalyzer) PrintDebugInfo() {
1735 - da.mu.RLock()
1736 - defer da.mu.RUnlock()
1737 -
1738 - fmt.Println("\n\nDEBUG INFORMATION:")
1739 - fmt.Println(strings.Repeat("-", 80))
1740 -
1741 - for jobName, job := range da.jobs {
1742 - fmt.Printf("\n[%s] Chart Structure:\n", jobName)
1743 -
1744 - for _, ca := range job.Charts {
1745 - fmt.Printf("\nChart ID: %s\n", ca.Chart.ID)
1746 - fmt.Printf(" Context: %s\n", ca.Chart.Ctx)
1747 - fmt.Printf(" Title: %s\n", ca.Chart.Title)
1748 - fmt.Printf(" Units: %s\n", ca.Chart.Units)
1749 - fmt.Printf(" Family: %s\n", ca.Chart.Fam)
1750 - fmt.Printf(" Type: %s\n", ca.Chart.Type)
1751 - fmt.Printf(" Priority: %d\n", ca.Chart.Priority)
1752 -
1753 - if len(ca.Chart.Labels) > 0 {
1754 - fmt.Printf(" Labels:\n")
1755 - for _, label := range ca.Chart.Labels {
1756 - fmt.Printf(" %s: %s\n", label.Key, label.Value)
1757 - }
1758 - }
1759 -
1760 - fmt.Printf(" Dimensions:\n")
1761 - for _, dim := range ca.Chart.Dims {
1762 - status := "INACTIVE"
1763 - valueCount := 0
1764 - if ca.SeenDimensions[dim.ID] {
1765 - status = "ACTIVE"
1766 - valueCount = len(ca.CollectedValues[dim.ID])
1767 - }
1768 -
1769 - fmt.Printf(" %s (%s) - %s [%d values collected]\n",
1770 - dim.ID, dim.Name, status, valueCount)
1771 -
1772 - // Show sample values if collected
1773 - if valueCount > 0 {
1774 - samples := ca.CollectedValues[dim.ID]
1775 - if valueCount > 5 {
1776 - fmt.Printf(" Sample values: %v ... %v\n",
1777 - samples[:3], samples[valueCount-2:])
1778 - } else {
1779 - fmt.Printf(" Values: %v\n", samples)
1780 - }
1781 - }
1782 - }
1783 - }
1784 - }
1785 -}
5 +// Dump analyzer implementation is split across dump_model.go,
6 +// dump_capture.go, and dump_report.go.
src/go/plugin/agent/dump_capture.go new
+281
@@ -0,0 +1,281 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package agent
4 +
5 +import (
6 + "encoding/json"
7 + "fmt"
8 + "os"
9 + "path/filepath"
10 + "sort"
11 + "time"
12 +
13 + "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
14 +)
15 +
16 +func (da *DumpAnalyzer) EnableDataCapture(dir string, onComplete func()) {
17 + da.mu.Lock()
18 + defer da.mu.Unlock()
19 + da.dataDir = dir
20 + da.onComplete = onComplete
21 +}
22 +
23 +// RegisterJob registers directory info for a job.
24 +func (da *DumpAnalyzer) RegisterJob(jobName, moduleName, dir string) {
25 + da.mu.Lock()
26 + defer da.mu.Unlock()
27 + if dir == "" {
28 + return
29 + }
30 + if da.jobDirs == nil {
31 + da.jobDirs = make(map[string]string)
32 + }
33 + da.jobDirs[jobName] = dir
34 + if da.jobDone == nil {
35 + da.jobDone = make(map[string]bool)
36 + }
37 + da.jobDone[jobName] = false
38 + // Ensure expected sub-directories exist
39 + _ = os.MkdirAll(filepath.Join(dir, "queries"), 0o755)
40 + _ = os.MkdirAll(filepath.Join(dir, "rows"), 0o755)
41 + _ = os.MkdirAll(filepath.Join(dir, "metrics"), 0o755)
42 + _ = os.MkdirAll(filepath.Join(dir, "meta"), 0o755)
43 +}
44 +
45 +// RecordJobStructure records the initial chart structure for a job
46 +func (da *DumpAnalyzer) RecordJobStructure(jobName, moduleName string, charts *collectorapi.Charts) {
47 + da.mu.Lock()
48 + defer da.mu.Unlock()
49 +
50 + job := &JobAnalysis{
51 + Name: jobName,
52 + Module: moduleName,
53 + Charts: make([]ChartAnalysis, 0),
54 + AllSeenMetrics: make(map[string]bool),
55 + }
56 +
57 + // Copy chart structure
58 + for _, chart := range *charts {
59 + ca := ChartAnalysis{
60 + Chart: chart,
61 + CollectedValues: make(map[string][]int64),
62 + SeenDimensions: make(map[string]bool),
63 + }
64 +
65 + // Initialize dimension tracking
66 + for _, dim := range chart.Dims {
67 + ca.CollectedValues[dim.ID] = make([]int64, 0)
68 + ca.SeenDimensions[dim.ID] = false
69 + }
70 +
71 + job.Charts = append(job.Charts, ca)
72 + }
73 +
74 + da.jobs[jobName] = job
75 + da.writeJobMetadata(jobName, moduleName)
76 +}
77 +
78 +// UpdateJobStructure updates the chart structure for a job with current charts
79 +// This is needed for collectors that create charts dynamically during collection
80 +func (da *DumpAnalyzer) UpdateJobStructure(jobName string, charts *collectorapi.Charts) {
81 + da.mu.Lock()
82 + defer da.mu.Unlock()
83 +
84 + job, exists := da.jobs[jobName]
85 + if !exists {
86 + return // Job not found, cannot update
87 + }
88 +
89 + // Create a map of existing chart data to preserve collected values
90 + existingCharts := make(map[string]*ChartAnalysis)
91 + for i := range job.Charts {
92 + existingCharts[job.Charts[i].Chart.ID] = &job.Charts[i]
93 + }
94 +
95 + // Rebuild chart list while preserving existing data
96 + job.Charts = make([]ChartAnalysis, 0)
97 +
98 + // Copy current chart structure
99 + for _, chart := range *charts {
100 + var ca ChartAnalysis
101 +
102 + // Check if we have existing data for this chart
103 + if existing, exists := existingCharts[chart.ID]; exists {
104 + // Preserve existing chart analysis but update the chart reference
105 + ca = *existing
106 + ca.Chart = chart
107 +
108 + // Add any new dimensions that weren't tracked before
109 + for _, dim := range chart.Dims {
110 + if _, tracked := ca.CollectedValues[dim.ID]; !tracked {
111 + ca.CollectedValues[dim.ID] = make([]int64, 0)
112 + ca.SeenDimensions[dim.ID] = false
113 + }
114 + }
115 + } else {
116 + // New chart - create fresh tracking
117 + ca = ChartAnalysis{
118 + Chart: chart,
119 + CollectedValues: make(map[string][]int64),
120 + SeenDimensions: make(map[string]bool),
121 + }
122 +
123 + // Initialize dimension tracking
124 + for _, dim := range chart.Dims {
125 + ca.CollectedValues[dim.ID] = make([]int64, 0)
126 + ca.SeenDimensions[dim.ID] = false
127 + }
128 + }
129 +
130 + job.Charts = append(job.Charts, ca)
131 + }
132 +}
133 +
134 +// RecordCollection records collected metrics directly from structured data
135 +func (da *DumpAnalyzer) RecordCollection(jobName string, mx map[string]int64) {
136 + da.mu.Lock()
137 + defer da.mu.Unlock()
138 +
139 + job, exists := da.jobs[jobName]
140 + if !exists {
141 + return
142 + }
143 +
144 + job.CollectionCount++
145 + job.LastCollection = time.Now()
146 +
147 + // Track ALL metrics in mx map
148 + for metricID := range mx {
149 + job.AllSeenMetrics[metricID] = true
150 + }
151 +
152 + // Record values for each chart
153 + for i := range job.Charts {
154 + ca := &job.Charts[i]
155 +
156 + // Check each dimension in this chart
157 + for _, dim := range ca.Chart.Dims {
158 + if value, collected := mx[dim.ID]; collected {
159 + ca.SeenDimensions[dim.ID] = true
160 + ca.CollectedValues[dim.ID] = append(ca.CollectedValues[dim.ID], value)
161 + }
162 + }
163 + }
164 +
165 + da.writeMetrics(jobName, job.CollectionCount, mx)
166 + da.markJobCollected(jobName)
167 +}
168 +
169 +func (da *DumpAnalyzer) writeJobMetadata(jobName, moduleName string) {
170 + if da.dataDir == "" {
171 + return
172 + }
173 + dir, ok := da.jobDirs[jobName]
174 + if !ok || dir == "" {
175 + return
176 + }
177 + meta := struct {
178 + Job string `json:"job"`
179 + Module string `json:"module"`
180 + Created time.Time `json:"created_at"`
181 + Metadata map[string]string `json:"metadata"`
182 + }{
183 + Job: jobName,
184 + Module: moduleName,
185 + Created: time.Now(),
186 + Metadata: map[string]string{
187 + "module": moduleName,
188 + },
189 + }
190 + path := filepath.Join(dir, "meta", "job.json")
191 + _ = writeJSON(path, meta)
192 +}
193 +
194 +func (da *DumpAnalyzer) writeMetrics(jobName string, seq int, mx map[string]int64) {
195 + if da.dataDir == "" {
196 + return
197 + }
198 + dir, ok := da.jobDirs[jobName]
199 + if !ok || dir == "" {
200 + return
201 + }
202 + metricsDir := filepath.Join(dir, "metrics")
203 + _ = os.MkdirAll(metricsDir, 0o755)
204 + payload := struct {
205 + CollectedAt time.Time `json:"collected_at"`
206 + Metrics map[string]int64 `json:"metrics"`
207 + }{
208 + CollectedAt: time.Now(),
209 + Metrics: mx,
210 + }
211 + filename := fmt.Sprintf("metrics-%04d.json", seq)
212 + path := filepath.Join(metricsDir, filename)
213 + _ = writeJSON(path, payload)
214 +}
215 +
216 +func (da *DumpAnalyzer) markJobCollected(jobName string) {
217 + if da.dataDir == "" {
218 + return
219 + }
220 + if da.jobDone == nil {
221 + return
222 + }
223 + da.jobDone[jobName] = true
224 + for job, dir := range da.jobDirs {
225 + if dir == "" {
226 + continue
227 + }
228 + if !da.jobDone[job] {
229 + return
230 + }
231 + }
232 + if da.completed {
233 + return
234 + }
235 + da.completed = true
236 + da.writeManifest()
237 + if da.onComplete != nil {
238 + go da.onComplete()
239 + }
240 +}
241 +
242 +func (da *DumpAnalyzer) writeManifest() {
243 + if da.dataDir == "" {
244 + return
245 + }
246 + type manifestJob struct {
247 + Name string `json:"name"`
248 + Module string `json:"module"`
249 + Directory string `json:"directory"`
250 + Collections int `json:"collections"`
251 + }
252 + var jobs []manifestJob
253 + for name, job := range da.jobs {
254 + dir := da.jobDirs[name]
255 + jobs = append(jobs, manifestJob{
256 + Name: name,
257 + Module: job.Module,
258 + Directory: dir,
259 + Collections: job.CollectionCount,
260 + })
261 + }
262 + sort.Slice(jobs, func(i, j int) bool { return jobs[i].Name < jobs[j].Name })
263 + manifest := struct {
264 + GeneratedAt time.Time `json:"generated_at"`
265 + Jobs []manifestJob `json:"jobs"`
266 + }{
267 + GeneratedAt: time.Now(),
268 + Jobs: jobs,
269 + }
270 + _ = writeJSON(filepath.Join(da.dataDir, "manifest.json"), manifest)
271 +}
272 +
273 +func writeJSON(path string, payload any) error {
274 + data, err := json.MarshalIndent(payload, "", " ")
275 + if err != nil {
276 + return err
277 + }
278 + return os.WriteFile(path, data, 0o644)
279 +}
280 +
281 +// contextInfo holds information about a context within a family
src/go/plugin/agent/dump_model.go new
+49
@@ -0,0 +1,49 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package agent
4 +
5 +import (
6 + "sync"
7 + "time"
8 +
9 + "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
10 +)
11 +
12 +// DumpAnalyzer collects and analyzes metric structure from dump mode
13 +type DumpAnalyzer struct {
14 + mu sync.RWMutex
15 + jobs map[string]*JobAnalysis // key: job name
16 + startTime time.Time
17 + dataDir string
18 + jobDirs map[string]string
19 + jobDone map[string]bool
20 + onComplete func()
21 + completed bool
22 +}
23 +
24 +// JobAnalysis holds analysis for a single job
25 +type JobAnalysis struct {
26 + Name string
27 + Module string
28 + Charts []ChartAnalysis
29 + CollectionCount int
30 + LastCollection time.Time
31 + AllSeenMetrics map[string]bool // Track ALL metrics seen in mx map
32 +}
33 +
34 +// ChartAnalysis holds analysis for a single chart
35 +type ChartAnalysis struct {
36 + Chart *collectorapi.Chart
37 + CollectedValues map[string][]int64 // dimension ID -> collected values
38 + SeenDimensions map[string]bool // track which dimensions received data
39 +}
40 +
41 +// NewDumpAnalyzer creates a new dump analyzer
42 +func NewDumpAnalyzer() *DumpAnalyzer {
43 + return &DumpAnalyzer{
44 + jobs: make(map[string]*JobAnalysis),
45 + startTime: time.Now(),
46 + jobDirs: make(map[string]string),
47 + jobDone: make(map[string]bool),
48 + }
49 +}
src/go/plugin/agent/dump_report.go new
+1473
@@ -0,0 +1,1473 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package agent
4 +
5 +import (
6 + "fmt"
7 + "sort"
8 + "strings"
9 +
10 + "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
11 +)
12 +
13 +func (da *DumpAnalyzer) PrintReport() {
14 + da.mu.RLock()
15 + defer da.mu.RUnlock()
16 +
17 + // Sort jobs for consistent output
18 + var jobNames []string
19 + for name := range da.jobs {
20 + jobNames = append(jobNames, name)
21 + }
22 + sort.Strings(jobNames)
23 +
24 + for _, jobName := range jobNames {
25 + job := da.jobs[jobName]
26 + da.printJobAnalysis(job)
27 + }
28 +}
29 +
30 +// PrintSummary prints a consolidated summary across all jobs
31 +func (da *DumpAnalyzer) PrintSummary() {
32 + da.mu.RLock()
33 + defer da.mu.RUnlock()
34 +
35 + // First print the regular report
36 + da.PrintReport()
37 +
38 + // Then print the consolidated summary
39 + fmt.Println("\n" + strings.Repeat("═", 80))
40 + fmt.Println("CONSOLIDATED SUMMARY ACROSS ALL JOBS")
41 + fmt.Println(strings.Repeat("═", 80))
42 +
43 + // Collect all contexts across all jobs
44 + type contextSummary struct {
45 + family string
46 + context string
47 + title string
48 + units string
49 + priority int
50 + chartType string
51 + labelKeys []string
52 + dimNames []string
53 + instances int
54 + jobs map[string]bool
55 + }
56 +
57 + contextMap := make(map[string]*contextSummary) // context -> summary
58 +
59 + for jobName, job := range da.jobs {
60 + for i := range job.Charts {
61 + ca := &job.Charts[i]
62 +
63 + ctx := ca.Chart.Ctx
64 + if _, exists := contextMap[ctx]; !exists {
65 + // Collect unique label keys
66 + labelKeysMap := make(map[string]bool)
67 + for _, label := range ca.Chart.Labels {
68 + labelKeysMap[label.Key] = true
69 + }
70 + labelKeys := []string{}
71 + for key := range labelKeysMap {
72 + labelKeys = append(labelKeys, key)
73 + }
74 + sort.Strings(labelKeys)
75 +
76 + // Collect unique dimension names
77 + dimNamesMap := make(map[string]bool)
78 + for _, dim := range ca.Chart.Dims {
79 + dimName := dim.Name
80 + if dimName == "" {
81 + dimName = dim.ID
82 + }
83 + dimNamesMap[dimName] = true
84 + }
85 + dimNames := []string{}
86 + for name := range dimNamesMap {
87 + dimNames = append(dimNames, name)
88 + }
89 + sort.Strings(dimNames)
90 +
91 + contextMap[ctx] = &contextSummary{
92 + family: ca.Chart.Fam,
93 + context: ctx,
94 + title: ca.Chart.Title,
95 + units: ca.Chart.Units,
96 + priority: ca.Chart.Priority,
97 + chartType: ca.Chart.Type.String(),
98 + labelKeys: labelKeys,
99 + dimNames: dimNames,
100 + instances: 0,
101 + jobs: make(map[string]bool),
102 + }
103 + }
104 +
105 + // Update instance count and job tracking
106 + contextMap[ctx].instances++
107 + contextMap[ctx].jobs[jobName] = true
108 +
109 + // Update label keys and dimension names if needed
110 + for _, label := range ca.Chart.Labels {
111 + found := false
112 + for _, key := range contextMap[ctx].labelKeys {
113 + if key == label.Key {
114 + found = true
115 + break
116 + }
117 + }
118 + if !found {
119 + contextMap[ctx].labelKeys = append(contextMap[ctx].labelKeys, label.Key)
120 + sort.Strings(contextMap[ctx].labelKeys)
121 + }
122 + }
123 +
124 + for _, dim := range ca.Chart.Dims {
125 + dimName := dim.Name
126 + if dimName == "" {
127 + dimName = dim.ID
128 + }
129 + found := false
130 + for _, name := range contextMap[ctx].dimNames {
131 + if name == dimName {
132 + found = true
133 + break
134 + }
135 + }
136 + if !found {
137 + contextMap[ctx].dimNames = append(contextMap[ctx].dimNames, dimName)
138 + sort.Strings(contextMap[ctx].dimNames)
139 + }
140 + }
141 + }
142 + }
143 +
144 + // Group contexts by family
145 + familyMap := make(map[string][]*contextSummary)
146 + for _, cs := range contextMap {
147 + family := cs.family
148 + if family == "" {
149 + family = "(no family)"
150 + }
151 + familyMap[family] = append(familyMap[family], cs)
152 + }
153 +
154 + // Sort families by their minimum priority (priority of their lowest-priority context)
155 + type familyPriority struct {
156 + family string
157 + minPriority int
158 + }
159 + var familyPriorities []familyPriority
160 + for fam, contexts := range familyMap {
161 + minPrio := contexts[0].priority
162 + for _, ctx := range contexts {
163 + if ctx.priority < minPrio {
164 + minPrio = ctx.priority
165 + }
166 + }
167 + familyPriorities = append(familyPriorities, familyPriority{family: fam, minPriority: minPrio})
168 + }
169 + sort.Slice(familyPriorities, func(i, j int) bool {
170 + return familyPriorities[i].minPriority < familyPriorities[j].minPriority
171 + })
172 +
173 + var families []string
174 + for _, fp := range familyPriorities {
175 + families = append(families, fp.family)
176 + }
177 +
178 + // Print summary with tree structure using colons
179 + for i, family := range families {
180 + if i == 0 {
181 + fmt.Printf("\n┌─ family: %s\n", family)
182 + } else {
183 + fmt.Printf("\n├─ family: %s\n", family)
184 + }
185 +
186 + // Sort contexts by priority
187 + contexts := familyMap[family]
188 + sort.Slice(contexts, func(i, j int) bool {
189 + return contexts[i].priority < contexts[j].priority
190 + })
191 +
192 + for j, cs := range contexts {
193 + isLastContext := j == len(contexts)-1
194 + contextPrefix := "├──"
195 + detailPrefix := "│ ├─"
196 + lastDetailPrefix := "│ └─"
197 +
198 + if isLastContext {
199 + contextPrefix = "└──"
200 + detailPrefix = " ├─"
201 + lastDetailPrefix = " └─"
202 + }
203 +
204 + fmt.Printf("│ %s context: %s, unit: %s, prio: %d, type: %s\n",
205 + contextPrefix, cs.context, cs.units, cs.priority, cs.chartType)
206 + fmt.Printf("│ %s title: %s\n", detailPrefix, cs.title)
207 +
208 + if len(cs.labelKeys) > 0 {
209 + fmt.Printf("│ %s labels: %s\n", detailPrefix, strings.Join(cs.labelKeys, ", "))
210 + } else {
211 + fmt.Printf("│ %s labels: (none)\n", detailPrefix)
212 + }
213 +
214 + fmt.Printf("│ %s dimensions: %s\n", detailPrefix, strings.Join(cs.dimNames, ", "))
215 + fmt.Printf("│ %s instances: %d, jobs: %d\n", lastDetailPrefix, cs.instances, len(cs.jobs))
216 + }
217 + }
218 +
219 + // Add a bottom border for the last family
220 + if len(families) > 0 {
221 + fmt.Println("└─────────────────────────────────────────────────────────────")
222 + }
223 +}
224 +
225 +type contextInfo struct {
226 + family string
227 + context string
228 + charts []*ChartAnalysis
229 + minPriority int
230 +}
231 +
232 +func (da *DumpAnalyzer) printJobAnalysis(job *JobAnalysis) {
233 + // First, check for duplicate chart IDs (SEVERE BUG)
234 + chartIDCounts := make(map[string]int)
235 + for i := range job.Charts {
236 + ca := &job.Charts[i]
237 + chartIDCounts[ca.Chart.ID]++
238 + }
239 +
240 + // Check for contexts appearing in multiple families (SEVERE BUG)
241 + contextToFamilies := make(map[string][]string)
242 +
243 + families := make(map[string]map[string]*contextInfo) // family -> context -> info
244 + familyMinPriority := make(map[string]int)
245 +
246 + // Track issues for summary
247 + contextIssues := make(map[string][]string)
248 +
249 + // Group charts and check for duplicate contexts
250 + for i := range job.Charts {
251 + ca := &job.Charts[i]
252 + family := ca.Chart.Fam
253 + if family == "" {
254 + family = "(no family)"
255 + }
256 + ctx := ca.Chart.Ctx
257 +
258 + // Track context to families mapping
259 + if _, exists := contextToFamilies[ctx]; !exists {
260 + contextToFamilies[ctx] = []string{}
261 + }
262 + if !contains(contextToFamilies[ctx], family) {
263 + contextToFamilies[ctx] = append(contextToFamilies[ctx], family)
264 + }
265 +
266 + // Initialize family if needed
267 + if _, exists := families[family]; !exists {
268 + families[family] = make(map[string]*contextInfo)
269 + familyMinPriority[family] = ca.Chart.Priority
270 + }
271 +
272 + // Update family minimum priority
273 + if ca.Chart.Priority < familyMinPriority[family] {
274 + familyMinPriority[family] = ca.Chart.Priority
275 + }
276 +
277 + // Initialize context if needed
278 + if _, exists := families[family][ctx]; !exists {
279 + families[family][ctx] = &contextInfo{
280 + family: family,
281 + context: ctx,
282 + charts: []*ChartAnalysis{},
283 + minPriority: ca.Chart.Priority,
284 + }
285 + }
286 +
287 + // Update context minimum priority
288 + if ca.Chart.Priority < families[family][ctx].minPriority {
289 + families[family][ctx].minPriority = ca.Chart.Priority
290 + }
291 +
292 + families[family][ctx].charts = append(families[family][ctx].charts, ca)
293 + }
294 +
295 + // Check for severe bugs - duplicate chart IDs and contexts in multiple families
296 + fmt.Println("\n" + job.Name)
297 +
298 + // Report duplicate chart IDs first (most severe)
299 + for chartID, count := range chartIDCounts {
300 + if count > 1 {
301 + fmt.Printf("🔴 SEVERE BUG: Chart ID '%s' defined %d times - this causes data corruption!\n",
302 + chartID, count)
303 + contextIssues[chartID] = append(contextIssues[chartID],
304 + fmt.Sprintf("SEVERE BUG - chart ID defined %d times (data corruption)", count))
305 + }
306 + }
307 +
308 + // Report contexts in multiple families
309 + for ctx, fams := range contextToFamilies {
310 + if len(fams) > 1 {
311 + fmt.Printf("🔴 SEVERE BUG: Context '%s' appears in multiple families: %s\n",
312 + ctx, strings.Join(fams, ", "))
313 + contextIssues[ctx] = append(contextIssues[ctx],
314 + fmt.Sprintf("SEVERE BUG - appears in multiple families: %s", strings.Join(fams, ", ")))
315 + }
316 + }
317 +
318 + // Check for duplicate dimension IDs across ALL charts (SEVERE BUG)
319 + allDimIDs := make(map[string][]string) // dimID -> []chartIDs
320 + for i := range job.Charts {
321 + ca := &job.Charts[i]
322 + for _, dim := range ca.Chart.Dims {
323 + if _, exists := allDimIDs[dim.ID]; !exists {
324 + allDimIDs[dim.ID] = []string{}
325 + }
326 + allDimIDs[dim.ID] = append(allDimIDs[dim.ID], ca.Chart.ID)
327 + }
328 + }
329 +
330 + // Report duplicate dimension IDs
331 + for dimID, chartIDs := range allDimIDs {
332 + if len(chartIDs) > 1 {
333 + fmt.Printf("🔴 SEVERE BUG: Dimension ID '%s' is used in %d charts: %s\n",
334 + dimID, len(chartIDs), strings.Join(chartIDs, ", "))
335 + // Add to issues for each affected context
336 + for _, chartID := range chartIDs {
337 + // Find the context for this chart
338 + for i := range job.Charts {
339 + if job.Charts[i].Chart.ID == chartID {
340 + ctx := job.Charts[i].Chart.Ctx
341 + contextIssues[ctx] = append(contextIssues[ctx],
342 + fmt.Sprintf("SEVERE BUG - dimension ID '%s' is shared with charts: %s", dimID, strings.Join(chartIDs, ", ")))
343 + break
344 + }
345 + }
346 + }
347 + }
348 + }
349 +
350 + // Proper excess metrics analysis
351 + da.analyzeMetricDimensionMatching(job, allDimIDs, contextIssues)
352 +
353 + // Family structure analysis
354 + da.analyzeFamilyStructureForJob(job, contextIssues)
355 +
356 + // Sort families by minimum priority
357 + var sortedFamilies []string
358 + for fam := range families {
359 + sortedFamilies = append(sortedFamilies, fam)
360 + }
361 + sort.Slice(sortedFamilies, func(i, j int) bool {
362 + return familyMinPriority[sortedFamilies[i]] < familyMinPriority[sortedFamilies[j]]
363 + })
364 +
365 + // Print analysis for each family
366 + for _, family := range sortedFamilies {
367 + fmt.Printf("\n├─ family= %s\n", family)
368 +
369 + // Sort contexts by minimum priority
370 + var sortedContexts []string
371 + for ctx := range families[family] {
372 + sortedContexts = append(sortedContexts, ctx)
373 + }
374 + sort.Slice(sortedContexts, func(i, j int) bool {
375 + return families[family][sortedContexts[i]].minPriority <
376 + families[family][sortedContexts[j]].minPriority
377 + })
378 +
379 + for i, ctx := range sortedContexts {
380 + isLast := i == len(sortedContexts)-1
381 + ctxInfo := families[family][ctx]
382 + issues := da.printContextAnalysis(ctxInfo, isLast)
383 + if len(issues) > 0 {
384 + contextIssues[ctx] = append(contextIssues[ctx], issues...)
385 + }
386 + }
387 + }
388 +
389 + // Print greppable summary
390 + fmt.Println("\n" + strings.Repeat("═", 80))
391 + fmt.Println("ISSUE SUMMARY (greppable)")
392 + fmt.Println(strings.Repeat("═", 80))
393 +
394 + errorCount := 0
395 + warningCount := 0
396 + infoCount := 0
397 + for ctx, issues := range contextIssues {
398 + if len(issues) > 0 {
399 + for _, issue := range issues {
400 + emoji := "❌"
401 + if strings.HasPrefix(issue, "INFO:") {
402 + emoji = "ℹ️"
403 + infoCount++
404 + } else if strings.Contains(issue, "WARNING") {
405 + emoji = "🟡"
406 + warningCount++
407 + } else if strings.Contains(issue, "SEVERE BUG") {
408 + emoji = "🔴"
409 + errorCount++
410 + } else {
411 + errorCount++
412 + }
413 + fmt.Printf("%s IDENTIFIED ISSUES ON %s: %s\n", emoji, ctx, issue)
414 + }
415 + }
416 + }
417 +
418 + issueCount := errorCount // Only count real errors for final status
419 +
420 + // Calculate statistics for the summary independently to avoid interfering with tree logic
421 + statsFamilies := make(map[string]bool)
422 + statsContexts := make(map[string]bool)
423 + statsInstances := 0
424 + statsTimeSeries := 0
425 + statsCollectedValues := 0
426 +
427 + // Calculate distinct {context}.{dimension} combinations
428 + uniqueContextDimensions := make(map[string]bool)
429 +
430 + for i := range job.Charts {
431 + ca := &job.Charts[i]
432 + statsInstances++
433 +
434 + // Track unique families and contexts for stats
435 + family := ca.Chart.Fam
436 + if family == "" {
437 + family = "(no family)"
438 + }
439 + statsFamilies[family] = true
440 + statsContexts[ca.Chart.Ctx] = true
441 +
442 + // Count dimensions (time-series) and track distinct {context}.{dimension} combinations
443 + statsTimeSeries += len(ca.Chart.Dims)
444 + for _, dim := range ca.Chart.Dims {
445 + statsCollectedValues += len(ca.CollectedValues[dim.ID])
446 + // Use dimension name for display, fall back to ID if name is empty
447 + dimName := dim.Name
448 + if dimName == "" {
449 + dimName = dim.ID
450 + }
451 + // Create unique key as context.dimension
452 + uniqueKey := fmt.Sprintf("%s.%s", ca.Chart.Ctx, dimName)
453 + uniqueContextDimensions[uniqueKey] = true
454 + }
455 + }
456 +
457 + // Count total distinct {context}.{dimension} combinations
458 + statsDistinctDimensions := len(uniqueContextDimensions)
459 +
460 + // Count unique metrics in mx map
461 + uniqueMetricsInMx := len(job.AllSeenMetrics)
462 +
463 + // Generate summary with detailed stats
464 + warningText := ""
465 + if warningCount > 0 {
466 + warningText = fmt.Sprintf(", %d warnings", warningCount)
467 + }
468 +
469 + if issueCount == 0 && statsTimeSeries == uniqueMetricsInMx {
470 + if warningCount > 0 {
471 + 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",
472 + warningText, job.Name, len(statsFamilies), len(statsContexts), statsDistinctDimensions, statsInstances, statsTimeSeries, uniqueMetricsInMx)
473 + } else {
474 + fmt.Printf("🟢 NO ISSUES FOUND, job %s defines: %d families, %d contexts, %d dimensions, %d instances, %d time-series, collects: %d unique metrics\n",
475 + job.Name, len(statsFamilies), len(statsContexts), statsDistinctDimensions, statsInstances, statsTimeSeries, uniqueMetricsInMx)
476 + }
477 + } else if issueCount == 0 && statsTimeSeries != uniqueMetricsInMx {
478 + // Mismatch between time-series and unique metrics even though no specific issues found
479 + fmt.Printf("🟡 DIMENSION MISMATCH%s, job %s defines: %d families, %d contexts, %d dimensions, %d instances, %d time-series, collects: %d unique metrics\n",
480 + warningText, job.Name, len(statsFamilies), len(statsContexts), statsDistinctDimensions, statsInstances, statsTimeSeries, uniqueMetricsInMx)
481 + } else {
482 + fmt.Printf("🔴 ISSUES FOUND%s, job %s defines: %d families, %d contexts, %d dimensions, %d instances, %d time-series, collects: %d unique metrics\n",
483 + warningText, job.Name, len(statsFamilies), len(statsContexts), statsDistinctDimensions, statsInstances, statsTimeSeries, uniqueMetricsInMx)
484 + }
485 +}
486 +
487 +func (da *DumpAnalyzer) printContextAnalysis(ctxInfo *contextInfo, isLast bool) []string {
488 + charts := ctxInfo.charts
489 + var issues []string
490 +
491 + // Analyze titles
492 + titles := make(map[string]int)
493 + for _, ca := range charts {
494 + titles[ca.Chart.Title]++
495 + }
496 +
497 + // Analyze units
498 + units := make(map[string]int)
499 + for _, ca := range charts {
500 + units[ca.Chart.Units]++
501 + }
502 +
503 + // Analyze priorities
504 + priorities := make(map[int]int)
505 + for _, ca := range charts {
506 + priorities[ca.Chart.Priority]++
507 + }
508 +
509 + // Analyze label keys
510 + labelKeysByChart := make(map[string]map[string]bool) // chartID -> set of keys
511 + allLabelKeys := make(map[string]bool)
512 + for _, ca := range charts {
513 + labelKeysByChart[ca.Chart.ID] = make(map[string]bool)
514 + for _, label := range ca.Chart.Labels {
515 + labelKeysByChart[ca.Chart.ID][label.Key] = true
516 + allLabelKeys[label.Key] = true
517 + }
518 + }
519 +
520 + // Analyze dimensions
521 + dimsByChart := make(map[string]map[string]*collectorapi.Dim) // chartID -> dimID -> dim
522 + allDimIDs := make(map[string]bool)
523 + for _, ca := range charts {
524 + dimsByChart[ca.Chart.ID] = make(map[string]*collectorapi.Dim)
525 + for _, dim := range ca.Chart.Dims {
526 + dimsByChart[ca.Chart.ID][dim.ID] = dim
527 + allDimIDs[dim.ID] = true
528 + }
529 + }
530 +
531 + // Tree prefixes
532 + ctxPrefix := "├─"
533 + treePrefix := "│ "
534 + if isLast {
535 + ctxPrefix = "└─"
536 + treePrefix = " "
537 + }
538 +
539 + // Print context header
540 + fmt.Printf("%s ⚡ context= %s\n", ctxPrefix, ctxInfo.context)
541 +
542 + // Print titles
543 + if len(titles) == 1 {
544 + for title := range titles {
545 + fmt.Printf("%s ├─ title= %s ✅\n", treePrefix, title)
546 + }
547 + } else {
548 + fmt.Printf("%s ├─ title= ❌ INCONSISTENT (%d different titles)\n", treePrefix, len(titles))
549 + for title, count := range titles {
550 + fmt.Printf("%s │ ├─ %s (in %d charts)\n", treePrefix, title, count)
551 + }
552 + issues = append(issues, fmt.Sprintf("inconsistent titles (%d different)", len(titles)))
553 + }
554 +
555 + // Print units, priority, and chart type on one line
556 + unitsStr := ""
557 + unitsEmoji := " ✅"
558 + if len(units) == 1 {
559 + for unit := range units {
560 + unitsStr = unit
561 + }
562 + } else {
563 + unitsStr = fmt.Sprintf("INCONSISTENT (%d different)", len(units))
564 + unitsEmoji = " ❌"
565 + issues = append(issues, fmt.Sprintf("inconsistent units (%d different)", len(units)))
566 + }
567 +
568 + priorityStr := ""
569 + priorityEmoji := " ✅"
570 + if len(priorities) == 1 {
571 + for priority := range priorities {
572 + priorityStr = fmt.Sprintf("%d", priority)
573 + }
574 + } else {
575 + priorityStr = fmt.Sprintf("%d (INCONSISTENT: %d different)", ctxInfo.minPriority, len(priorities))
576 + priorityEmoji = " 🟡"
577 + issues = append(issues, fmt.Sprintf("inconsistent priorities (%d different)", len(priorities)))
578 + }
579 +
580 + // Collect chart types
581 + chartTypes := make(map[string]int)
582 + for _, ca := range charts {
583 + chartTypes[ca.Chart.Type.String()]++
584 + }
585 +
586 + typeStr := ""
587 + typeEmoji := " ✅"
588 + if len(chartTypes) == 1 {
589 + for typ := range chartTypes {
590 + typeStr = typ
591 + }
592 + } else {
593 + typeStr = fmt.Sprintf("INCONSISTENT (%d different)", len(chartTypes))
594 + typeEmoji = " ❌"
595 + issues = append(issues, fmt.Sprintf("inconsistent chart types (%d different)", len(chartTypes)))
596 + }
597 +
598 + fmt.Printf("%s ├─ units= %s%s, priority= %s%s, type= %s%s\n",
599 + treePrefix, unitsStr, unitsEmoji, priorityStr, priorityEmoji, typeStr, typeEmoji)
600 +
601 + // Print label keys
602 + fmt.Printf("%s ├─ label keys= ", treePrefix)
603 + if len(allLabelKeys) == 0 {
604 + fmt.Printf("(none) ✅\n")
605 + } else {
606 + var labelKeyList []string
607 + for key := range allLabelKeys {
608 + labelKeyList = append(labelKeyList, key)
609 + }
610 + sort.Strings(labelKeyList)
611 +
612 + var labelKeyStatus []string
613 + hasInconsistentLabels := false
614 + for _, key := range labelKeyList {
615 + allHaveIt := true
616 + for chartID := range labelKeysByChart {
617 + if !labelKeysByChart[chartID][key] {
618 + allHaveIt = false
619 + hasInconsistentLabels = true
620 + break
621 + }
622 + }
623 + if allHaveIt {
624 + labelKeyStatus = append(labelKeyStatus, fmt.Sprintf("%s✅", key))
625 + } else {
626 + labelKeyStatus = append(labelKeyStatus, fmt.Sprintf("%s❌", key))
627 + }
628 + }
629 + fmt.Printf("%s", strings.Join(labelKeyStatus, ", "))
630 + if hasInconsistentLabels {
631 + fmt.Printf(" 🟡 SOME MISSING")
632 + issues = append(issues, "WARNING - inconsistent label keys (natural for heterogeneous instances)")
633 + }
634 + fmt.Printf("\n")
635 + }
636 +
637 + // Collect all dimension names across all charts
638 + dimNamesByChart := make(map[string]map[string]string) // chartID -> dimName -> dimID
639 + allDimNames := make(map[string]bool)
640 +
641 + for _, ca := range charts {
642 + dimNamesByChart[ca.Chart.ID] = make(map[string]string)
643 + for _, dim := range ca.Chart.Dims {
644 + name := dim.Name
645 + if name == "" {
646 + name = dim.ID
647 + }
648 + dimNamesByChart[ca.Chart.ID][name] = dim.ID
649 + allDimNames[name] = true
650 + }
651 + }
652 +
653 + // Print dimensions (names only at context level)
654 + fmt.Printf("%s ├─ dimensions=\n", treePrefix)
655 + var dimNameList []string
656 + for dimName := range allDimNames {
657 + dimNameList = append(dimNameList, dimName)
658 + }
659 + sort.Strings(dimNameList)
660 +
661 + // Check multipliers, dividers, and algorithms consistency across all charts for each dimension
662 + dimMultDivInfo := make(map[string]map[string][]int) // dimName -> "mul"/"div" -> []values
663 + dimAlgoInfo := make(map[string][]string) // dimName -> []algorithms
664 + contextAlgorithms := make(map[string]bool) // track all algorithms used in this context
665 +
666 + for dimName := range allDimNames {
667 + dimMultDivInfo[dimName] = map[string][]int{
668 + "mul": {},
669 + "div": {},
670 + }
671 + dimAlgoInfo[dimName] = []string{}
672 +
673 + // Collect all multipliers, dividers, and algorithms for this dimension name across charts
674 + for _, ca := range charts {
675 + for _, dim := range ca.Chart.Dims {
676 + name := dim.Name
677 + if name == "" {
678 + name = dim.ID
679 + }
680 + if name == dimName {
681 + // Treat 0 as 1 (default value)
682 + mul := dim.Mul
683 + if mul == 0 {
684 + mul = 1
685 + }
686 + div := dim.Div
687 + if div == 0 {
688 + div = 1
689 + }
690 + dimMultDivInfo[dimName]["mul"] = append(dimMultDivInfo[dimName]["mul"], mul)
691 + dimMultDivInfo[dimName]["div"] = append(dimMultDivInfo[dimName]["div"], div)
692 +
693 + // Collect algorithm
694 + algo := dim.Algo.String()
695 + dimAlgoInfo[dimName] = append(dimAlgoInfo[dimName], algo)
696 + contextAlgorithms[algo] = true
697 + }
698 + }
699 + }
700 + }
701 +
702 + // Check for mixed algorithms in the context
703 + if len(contextAlgorithms) > 1 {
704 + algoList := []string{}
705 + for algo := range contextAlgorithms {
706 + algoList = append(algoList, algo)
707 + }
708 + sort.Strings(algoList)
709 + issues = append(issues, fmt.Sprintf("mixed dimension algorithms (%s)", strings.Join(algoList, ", ")))
710 + }
711 +
712 + // Check for rate units with absolute algorithm
713 + if len(units) == 1 && len(contextAlgorithms) == 1 {
714 + for unit := range units {
715 + for algo := range contextAlgorithms {
716 + // Check if unit contains rate indicator (per second, per minute, etc.)
717 + if strings.Contains(unit, "/") && algo == "absolute" {
718 + issues = append(issues, fmt.Sprintf("WARNING - rate unit '%s' with absolute algorithm (should use incremental)", unit))
719 + }
720 + }
721 + }
722 + }
723 +
724 + // Check for generic units that indicate mixed metric types
725 + if len(units) == 1 {
726 + for unit := range units {
727 + lowerUnit := strings.ToLower(unit)
728 + // Check for generic counting units
729 + if lowerUnit == "value" || lowerUnit == "values" ||
730 + lowerUnit == "count" || lowerUnit == "counts" ||
731 + lowerUnit == "number" || lowerUnit == "numbers" ||
732 + lowerUnit == "amount" || lowerUnit == "amounts" ||
733 + lowerUnit == "quantity" || lowerUnit == "quantities" {
734 + issues = append(issues, fmt.Sprintf("WARNING - generic unit '%s' suggests mixed metric types (apples and oranges)", unit))
735 + }
736 + }
737 + }
738 +
739 + hasMissingDims := false
740 + hasMultDivInconsistency := false
741 + for i, dimName := range dimNameList {
742 + // Check if all charts have this dimension name
743 + allHaveIt := true
744 + for chartID := range dimNamesByChart {
745 + if _, exists := dimNamesByChart[chartID][dimName]; !exists {
746 + allHaveIt = false
747 + hasMissingDims = true
748 + break
749 + }
750 + }
751 +
752 + prefix := "├─"
753 + if i == len(dimNameList)-1 {
754 + prefix = "└─"
755 + }
756 +
757 + dimStatus := ""
758 + if !allHaveIt {
759 + dimStatus = " 🟡 NOT IN ALL CHARTS"
760 + }
761 +
762 + // Check multiplier/divider consistency
763 + mulValues := dimMultDivInfo[dimName]["mul"]
764 + divValues := dimMultDivInfo[dimName]["div"]
765 + algoValues := dimAlgoInfo[dimName]
766 +
767 + // Get unique multipliers, dividers, and algorithms
768 + uniqueMuls := make(map[int]bool)
769 + uniqueDivs := make(map[int]bool)
770 + uniqueAlgos := make(map[string]bool)
771 + for _, m := range mulValues {
772 + uniqueMuls[m] = true
773 + }
774 + for _, d := range divValues {
775 + uniqueDivs[d] = true
776 + }
777 + for _, a := range algoValues {
778 + uniqueAlgos[a] = true
779 + }
780 +
781 + // Format multiplier/divider/algorithm info
782 + multDivAlgoStr := ""
783 + multDivAlgoEmoji := " ✅"
784 +
785 + // Check consistency
786 + if len(uniqueMuls) > 1 || len(uniqueDivs) > 1 || len(uniqueAlgos) > 1 {
787 + hasMultDivInconsistency = true
788 + multDivAlgoEmoji = " ❌"
789 + }
790 +
791 + // Format the multiplier/divider/algorithm string - ALWAYS show them
792 + if len(uniqueMuls) == 1 && len(uniqueDivs) == 1 && len(uniqueAlgos) == 1 {
793 + var mul, div int
794 + var algo string
795 + for m := range uniqueMuls {
796 + mul = m
797 + }
798 + for d := range uniqueDivs {
799 + div = d
800 + }
801 + for a := range uniqueAlgos {
802 + algo = a
803 + }
804 +
805 + // Always show multiplier, divider, and algorithm
806 + multDivAlgoStr = fmt.Sprintf(" ×%d ÷%d %s", mul, div, algo)
807 + } else {
808 + // Show all variations if inconsistent
809 + parts := []string{}
810 +
811 + if len(uniqueMuls) == 1 {
812 + var mul int
813 + for m := range uniqueMuls {
814 + mul = m
815 + }
816 + parts = append(parts, fmt.Sprintf("×%d", mul))
817 + } else {
818 + mulStrs := []string{}
819 + for m := range uniqueMuls {
820 + mulStrs = append(mulStrs, fmt.Sprintf("%d", m))
821 + }
822 + parts = append(parts, fmt.Sprintf("×(%s)", strings.Join(mulStrs, ",")))
823 + }
824 +
825 + if len(uniqueDivs) == 1 {
826 + var div int
827 + for d := range uniqueDivs {
828 + div = d
829 + }
830 + parts = append(parts, fmt.Sprintf("÷%d", div))
831 + } else {
832 + divStrs := []string{}
833 + for d := range uniqueDivs {
834 + divStrs = append(divStrs, fmt.Sprintf("%d", d))
835 + }
836 + parts = append(parts, fmt.Sprintf("÷(%s)", strings.Join(divStrs, ",")))
837 + }
838 +
839 + if len(uniqueAlgos) == 1 {
840 + var algo string
841 + for a := range uniqueAlgos {
842 + algo = a
843 + }
844 + parts = append(parts, algo)
845 + } else {
846 + algoStrs := []string{}
847 + for a := range uniqueAlgos {
848 + algoStrs = append(algoStrs, a)
849 + }
850 + sort.Strings(algoStrs)
851 + parts = append(parts, fmt.Sprintf("(%s)", strings.Join(algoStrs, ",")))
852 + }
853 +
854 + multDivAlgoStr = fmt.Sprintf(" %s", strings.Join(parts, " "))
855 + }
856 +
857 + fmt.Printf("%s │ %s %s%s%s%s\n", treePrefix, prefix, dimName, multDivAlgoStr, multDivAlgoEmoji, dimStatus)
858 + }
859 +
860 + if hasMissingDims {
861 + issues = append(issues, "WARNING - missing dimensions in some charts (natural for heterogeneous instances)")
862 + }
863 +
864 + if hasMultDivInconsistency {
865 + // Add detailed multiplier/divider inconsistency issues
866 + for dimName, info := range dimMultDivInfo {
867 + mulValues := info["mul"]
868 + divValues := info["div"]
869 +
870 + uniqueMuls := make(map[int]int)
871 + uniqueDivs := make(map[int]int)
872 + for _, m := range mulValues {
873 + uniqueMuls[m]++
874 + }
875 + for _, d := range divValues {
876 + uniqueDivs[d]++
877 + }
878 +
879 + if len(uniqueMuls) > 1 {
880 + mulStrs := []string{}
881 + for m, count := range uniqueMuls {
882 + mulStrs = append(mulStrs, fmt.Sprintf("%d (in %d charts)", m, count))
883 + }
884 + issues = append(issues, fmt.Sprintf("dimension '%s' has inconsistent multipliers: %s", dimName, strings.Join(mulStrs, ", ")))
885 + }
886 +
887 + if len(uniqueDivs) > 1 {
888 + divStrs := []string{}
889 + for d, count := range uniqueDivs {
890 + divStrs = append(divStrs, fmt.Sprintf("%d (in %d charts)", d, count))
891 + }
892 + issues = append(issues, fmt.Sprintf("dimension '%s' has inconsistent dividers: %s", dimName, strings.Join(divStrs, ", ")))
893 + }
894 + }
895 + }
896 +
897 + // Check if any dimensions are missing data across all instances
898 + missingDataDetails := []string{}
899 + for _, ca := range charts {
900 + for _, dim := range ca.Chart.Dims {
901 + if !ca.SeenDimensions[dim.ID] || len(ca.CollectedValues[dim.ID]) == 0 {
902 + dimName := dim.Name
903 + if dimName == "" {
904 + dimName = dim.ID
905 + }
906 + // Show both ID and name for clarity
907 + dimInfo := fmt.Sprintf("'%s'", dim.ID)
908 + if dim.Name != "" && dim.Name != dim.ID {
909 + dimInfo = fmt.Sprintf("'%s' ('%s')", dim.ID, dim.Name)
910 + }
911 + missingDataDetails = append(missingDataDetails, fmt.Sprintf("dimension %s on chart '%s' is not collected", dimInfo, ca.Chart.ID))
912 + }
913 + }
914 + }
915 +
916 + // Add all missing data issues
917 + issues = append(issues, missingDataDetails...)
918 +
919 + // Print instances
920 + fmt.Printf("%s └─ instances=\n", treePrefix)
921 + for i, ca := range charts {
922 + labelPairs := []string{}
923 + for _, label := range ca.Chart.Labels {
924 + labelPairs = append(labelPairs, fmt.Sprintf("%s=%s", label.Key, label.Value))
925 + }
926 + labelStr := ""
927 + if len(labelPairs) > 0 {
928 + labelStr = fmt.Sprintf(" {%s}", strings.Join(labelPairs, ", "))
929 + }
930 +
931 + // Extract name from ID if possible
932 + name := ""
933 + if ca.Chart.OverID != "" {
934 + name = ca.Chart.OverID
935 + }
936 +
937 + instPrefix := "├─"
938 + instTreePrefix := "│ "
939 + if i == len(charts)-1 {
940 + instPrefix = "└─"
941 + instTreePrefix = " "
942 + }
943 +
944 + fmt.Printf("%s %s %s (%s)%s\n", treePrefix, instPrefix, ca.Chart.ID, name, labelStr)
945 +
946 + // Print dimension status for this instance
947 + for _, dim := range ca.Chart.Dims {
948 + dimName := dim.Name
949 + if dimName == "" {
950 + dimName = dim.ID
951 + }
952 +
953 + emoji := "❌"
954 + valueStr := ""
955 + if ca.SeenDimensions[dim.ID] && len(ca.CollectedValues[dim.ID]) > 0 {
956 + emoji = "✅"
957 +
958 + // Format sample values
959 + values := ca.CollectedValues[dim.ID]
960 + if len(values) > 5 {
961 + // Show first 3 and last 2 values for long series
962 + firstVals := []string{}
963 + for i := 0; i < 3; i++ {
964 + firstVals = append(firstVals, fmt.Sprintf("%d", values[i]))
965 + }
966 + lastVals := []string{}
967 + for i := len(values) - 2; i < len(values); i++ {
968 + lastVals = append(lastVals, fmt.Sprintf("%d", values[i]))
969 + }
970 + valueStr = fmt.Sprintf(": [%s, ..., %s] ", strings.Join(firstVals, ", "), strings.Join(lastVals, ", "))
971 + } else {
972 + // Show all values for short series
973 + valStrs := []string{}
974 + for _, v := range values {
975 + valStrs = append(valStrs, fmt.Sprintf("%d", v))
976 + }
977 + valueStr = fmt.Sprintf(": [%s] ", strings.Join(valStrs, ", "))
978 + }
979 + }
980 +
981 + // Format multiplier/divider and algorithm for this specific dimension
982 + mul := dim.Mul
983 + div := dim.Div
984 + // Treat 0 as 1 (what the framework does)
985 + if mul == 0 {
986 + mul = 1
987 + }
988 + if div == 0 {
989 + div = 1
990 + }
991 +
992 + // Get algorithm
993 + algo := string(dim.Algo)
994 + if algo == "" {
995 + algo = "absolute"
996 + }
997 +
998 + // Always show multiplier, divider and algorithm
999 + multDivAlgoStr := fmt.Sprintf(" ×%d ÷%d %s", mul, div, algo)
1000 +
1001 + fmt.Printf("%s %s %s %s%s%s %s\n", treePrefix, instTreePrefix, emoji, dimName, multDivAlgoStr, valueStr, dim.ID)
1002 + }
1003 +
1004 + }
1005 +
1006 + return issues
1007 +}
1008 +
1009 +func contains(slice []string, item string) bool {
1010 + for _, s := range slice {
1011 + if s == item {
1012 + return true
1013 + }
1014 + }
1015 + return false
1016 +}
1017 +
1018 +// analyzeMetricDimensionMatching performs comprehensive analysis of dimension/metric matching
1019 +func (da *DumpAnalyzer) analyzeMetricDimensionMatching(job *JobAnalysis, allDimIDs map[string][]string, contextIssues map[string][]string) {
1020 + // 1. Find duplicate dimension IDs across charts (already done above but let's be explicit)
1021 + duplicateDimensions := []string{}
1022 + for dimID, chartIDs := range allDimIDs {
1023 + if len(chartIDs) > 1 {
1024 + duplicateDimensions = append(duplicateDimensions, dimID)
1025 + // Find affected contexts
1026 + affectedContexts := make(map[string]bool)
1027 + for _, chartID := range chartIDs {
1028 + for i := range job.Charts {
1029 + if job.Charts[i].Chart.ID == chartID {
1030 + affectedContexts[job.Charts[i].Chart.Ctx] = true
1031 + break
1032 + }
1033 + }
1034 + }
1035 + for ctx := range affectedContexts {
1036 + contextIssues[ctx] = append(contextIssues[ctx],
1037 + fmt.Sprintf("SEVERE BUG - dimension '%s' is used in multiple charts: %s", dimID, strings.Join(chartIDs, ", ")))
1038 + }
1039 + }
1040 + }
1041 +
1042 + // 2. Get unique dimension IDs from charts
1043 + chartDimensions := make(map[string]bool)
1044 + for dimID := range allDimIDs {
1045 + chartDimensions[dimID] = true
1046 + }
1047 +
1048 + // 3. Get unique dimension IDs from values map (AllSeenMetrics)
1049 + valuesDimensions := make(map[string]bool)
1050 + for metricID := range job.AllSeenMetrics {
1051 + valuesDimensions[metricID] = true
1052 + }
1053 +
1054 + // 4. Find dimensions in charts but not in values (missing data)
1055 + missingValues := []string{}
1056 + for dimID := range chartDimensions {
1057 + if !valuesDimensions[dimID] {
1058 + missingValues = append(missingValues, dimID)
1059 + }
1060 + }
1061 +
1062 + // 5. Find dimensions in values but not in charts (excess metrics)
1063 + excessMetrics := []string{}
1064 + for metricID := range valuesDimensions {
1065 + if !chartDimensions[metricID] {
1066 + excessMetrics = append(excessMetrics, metricID)
1067 + }
1068 + }
1069 +
1070 + // Group missing values by context for reporting
1071 + if len(missingValues) > 0 {
1072 + contextMissingValues := make(map[string][]string)
1073 + for _, dimID := range missingValues {
1074 + // Find which context this dimension belongs to
1075 + for i := range job.Charts {
1076 + ca := &job.Charts[i]
1077 + for _, dim := range ca.Chart.Dims {
1078 + if dim.ID == dimID {
1079 + contextMissingValues[ca.Chart.Ctx] = append(contextMissingValues[ca.Chart.Ctx], dimID)
1080 + break
1081 + }
1082 + }
1083 + }
1084 + }
1085 +
1086 + for ctx, dims := range contextMissingValues {
1087 + sort.Strings(dims)
1088 + contextIssues[ctx] = append(contextIssues[ctx],
1089 + fmt.Sprintf("dimensions %s in charts do not have collected values", strings.Join(dims, ", ")))
1090 + }
1091 + }
1092 +
1093 + // Report excess metrics
1094 + if len(excessMetrics) > 0 {
1095 + sort.Strings(excessMetrics)
1096 + contextIssues["_general"] = append(contextIssues["_general"],
1097 + fmt.Sprintf("dimensions %s in the values map, do not exist in charts", strings.Join(excessMetrics, ", ")))
1098 + }
1099 +
1100 + // Print success messages with counts if no issues
1101 + if len(duplicateDimensions) == 0 {
1102 + fmt.Printf("✅ DIMENSION UNIQUENESS: All %d dimensions have unique IDs across charts\n", len(chartDimensions))
1103 + }
1104 +
1105 + if len(missingValues) == 0 && len(excessMetrics) == 0 {
1106 + fmt.Printf("✅ DIMENSION/VALUES MATCHING: %d chart dimensions perfectly match %d collected values\n",
1107 + len(chartDimensions), len(valuesDimensions))
1108 + } else {
1109 + if len(missingValues) > 0 {
1110 + fmt.Printf("❌ MISSING VALUES: %d chart dimensions have no collected values\n", len(missingValues))
1111 + }
1112 + if len(excessMetrics) > 0 {
1113 + fmt.Printf("❌ EXCESS VALUES: %d collected values have no corresponding chart dimensions\n", len(excessMetrics))
1114 + }
1115 + }
1116 +}
1117 +
1118 +// gcd calculates the greatest common divisor
1119 +func gcd(a, b int) int {
1120 + for b != 0 {
1121 + a, b = b, a%b
1122 + }
1123 + return a
1124 +}
1125 +
1126 +// analyzeFamilyStructureForJob performs family-level structural analysis for a single job
1127 +func (da *DumpAnalyzer) analyzeFamilyStructureForJob(job *JobAnalysis, contextIssues map[string][]string) {
1128 + // Get all charts from this job
1129 + allCharts := []*ChartAnalysis{}
1130 + for i := range job.Charts {
1131 + allCharts = append(allCharts, &job.Charts[i])
1132 + }
1133 +
1134 + // Group charts by family
1135 + type familyInfo struct {
1136 + contexts map[string][]*ChartAnalysis // context -> charts
1137 + labelPairs map[string]int // "key=value" -> count
1138 + hasSubfamilies bool
1139 + subfamilies map[string]bool
1140 + }
1141 +
1142 + families := make(map[string]*familyInfo) // family -> info
1143 + topLevelFamilies := make(map[string]bool)
1144 +
1145 + for _, ca := range allCharts {
1146 + family := ca.Chart.Fam
1147 + if family == "" {
1148 + family = "(no family)"
1149 + }
1150 +
1151 + // We'll check family depth later after all families are processed
1152 +
1153 + // Extract top-level family
1154 + topLevel := family
1155 + if idx := strings.Index(family, "/"); idx != -1 {
1156 + topLevel = family[:idx]
1157 + }
1158 + topLevelFamilies[topLevel] = true
1159 +
1160 + // Initialize family info
1161 + if _, exists := families[family]; !exists {
1162 + families[family] = &familyInfo{
1163 + contexts: make(map[string][]*ChartAnalysis),
1164 + labelPairs: make(map[string]int),
1165 + subfamilies: make(map[string]bool),
1166 + }
1167 + }
1168 +
1169 + // Track contexts
1170 + ctx := ca.Chart.Ctx
1171 + families[family].contexts[ctx] = append(families[family].contexts[ctx], ca)
1172 +
1173 + // Track label pairs
1174 + for _, label := range ca.Chart.Labels {
1175 + pair := fmt.Sprintf("%s=%s", label.Key, label.Value)
1176 + families[family].labelPairs[pair]++
1177 + }
1178 +
1179 + // Check for subfamilies
1180 + if strings.Contains(family, "/") {
1181 + parentFamily := family[:strings.Index(family, "/")]
1182 + if _, exists := families[parentFamily]; !exists {
1183 + families[parentFamily] = &familyInfo{
1184 + contexts: make(map[string][]*ChartAnalysis),
1185 + labelPairs: make(map[string]int),
1186 + subfamilies: make(map[string]bool),
1187 + }
1188 + }
1189 + families[parentFamily].hasSubfamilies = true
1190 + families[parentFamily].subfamilies[family] = true
1191 + }
1192 + }
1193 +
1194 + // Rule 0: Check family depth (deferred until all families are processed)
1195 + for family, info := range families {
1196 + slashCount := strings.Count(family, "/")
1197 + if slashCount > 2 {
1198 + // Add to the first context in this family
1199 + for ctx := range info.contexts {
1200 + contextIssues[ctx] = append(contextIssues[ctx],
1201 + 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))
1202 + break
1203 + }
1204 + }
1205 + }
1206 +
1207 + // Rule 1: Check label consistency within families
1208 + for family, info := range families {
1209 + if len(info.contexts) < 2 {
1210 + continue // Skip single-context families
1211 + }
1212 +
1213 + // Calculate total charts in this family
1214 + totalCharts := 0
1215 + for _, charts := range info.contexts {
1216 + totalCharts += len(charts)
1217 + }
1218 +
1219 + // Find inconsistent label pairs
1220 + inconsistentPairs := []string{}
1221 +
1222 + // The base unit is the number of contexts in the family
1223 + // Each label key-value pair should appear in multiples of this
1224 + baseUnit := len(info.contexts)
1225 +
1226 + // Check that each label pair count is a multiple of the base unit
1227 + for pair, actualCount := range info.labelPairs {
1228 + if baseUnit > 0 && actualCount%baseUnit != 0 {
1229 + inconsistentPairs = append(inconsistentPairs, fmt.Sprintf("'%s': %d", pair, actualCount))
1230 + }
1231 + }
1232 +
1233 + if len(inconsistentPairs) > 0 {
1234 + // Limit to first 10 pairs for readability
1235 + displayPairs := inconsistentPairs
1236 + if len(inconsistentPairs) > 10 {
1237 + displayPairs = inconsistentPairs[:10]
1238 + displayPairs = append(displayPairs, fmt.Sprintf("... and %d more", len(inconsistentPairs)-10))
1239 + }
1240 +
1241 + // Add to all contexts in this family
1242 + for ctx := range info.contexts {
1243 + contextIssues[ctx] = append(contextIssues[ctx],
1244 + 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",
1245 + family, baseUnit, strings.Join(displayPairs, ", ")))
1246 + }
1247 + }
1248 + }
1249 +
1250 + // Rule 2: Check same number of instances per context in a family
1251 + for family, info := range families {
1252 + if len(info.contexts) > 1 {
1253 + instanceCounts := make(map[int][]string)
1254 + for ctx, charts := range info.contexts {
1255 + count := len(charts)
1256 + instanceCounts[count] = append(instanceCounts[count], ctx)
1257 + }
1258 +
1259 + if len(instanceCounts) > 1 {
1260 + details := []string{}
1261 + for count, contexts := range instanceCounts {
1262 + details = append(details, fmt.Sprintf("%d instances: %s", count, strings.Join(contexts, ", ")))
1263 + }
1264 + // Add to all contexts in this family
1265 + for ctx := range info.contexts {
1266 + contextIssues[ctx] = append(contextIssues[ctx],
1267 + 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",
1268 + family, strings.Join(details, "; ")))
1269 + }
1270 + }
1271 + }
1272 + }
1273 +
1274 + // Rule 3: Check snake_case contexts
1275 + for _, ca := range allCharts {
1276 + ctx := ca.Chart.Ctx
1277 + if !isSnakeCase(ctx) {
1278 + contextIssues[ctx] = append(contextIssues[ctx],
1279 + 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))
1280 + }
1281 + }
1282 +
1283 + // Rule 4: Check families with >15 contexts
1284 + for family, info := range families {
1285 + if len(info.contexts) > 15 {
1286 + // Add to all contexts in this family
1287 + for ctx := range info.contexts {
1288 + contextIssues[ctx] = append(contextIssues[ctx],
1289 + 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",
1290 + family, len(info.contexts)))
1291 + }
1292 + }
1293 + }
1294 +
1295 + // Rule 5: Check generic family names
1296 + genericFamilies := map[string]bool{
1297 + "other": true,
1298 + "infrastructure": true,
1299 + "runtime": true,
1300 + }
1301 +
1302 + for family := range families {
1303 + // Check only the base family name (before /)
1304 + baseName := family
1305 + if idx := strings.Index(family, "/"); idx != -1 {
1306 + baseName = family[:idx]
1307 + }
1308 +
1309 + if genericFamilies[strings.ToLower(baseName)] {
1310 + // Add to all contexts in this family
1311 + for ctx := range families[family].contexts {
1312 + contextIssues[ctx] = append(contextIssues[ctx],
1313 + fmt.Sprintf("family '%s' uses generic name '%s'; possible cause: unclear categorization; possible fix: use specific names like 'database', 'webserver', 'messaging', etc.",
1314 + family, baseName))
1315 + }
1316 + }
1317 + }
1318 +
1319 + // Rule 6: Check families with both direct contexts and subfamilies
1320 + for family, info := range families {
1321 + if len(info.contexts) > 0 && info.hasSubfamilies {
1322 + // This is a parent family with both direct contexts and subfamilies
1323 + if !strings.Contains(family, "/") {
1324 + // Add to all contexts in this family
1325 + for ctx := range info.contexts {
1326 + contextIssues[ctx] = append(contextIssues[ctx],
1327 + fmt.Sprintf("family '%s' has both direct contexts and subfamilies; possible cause: mixed hierarchy; possible fix: move direct contexts to '%s/overview' or similar",
1328 + family, family))
1329 + }
1330 + }
1331 + }
1332 + }
1333 +
1334 + // Rule 7: Check top-level family count
1335 + if len(topLevelFamilies) > 15 {
1336 + familyList := []string{}
1337 + for f := range topLevelFamilies {
1338 + familyList = append(familyList, f)
1339 + }
1340 + sort.Strings(familyList)
1341 +
1342 + // Add to general issues (first context found)
1343 + for _, ca := range allCharts {
1344 + contextIssues[ca.Chart.Ctx] = append(contextIssues[ca.Chart.Ctx],
1345 + fmt.Sprintf("found %d top-level families (exceeds recommended 15): %s; possible cause: too many categories; possible fix: consolidate related families or use subfamilies",
1346 + len(topLevelFamilies), strings.Join(familyList, ", ")))
1347 + break // Only add once
1348 + }
1349 + }
1350 +
1351 + // Rule 8: Check subfamily counts
1352 + for family, info := range families {
1353 + if !strings.Contains(family, "/") && info.hasSubfamilies {
1354 + // This is a parent family, check its subfamilies
1355 + subfamilyCount := len(info.subfamilies)
1356 +
1357 + // Check for singleton subfamily without siblings
1358 + if subfamilyCount == 1 {
1359 + // Get the single subfamily name
1360 + var singleSubfamily string
1361 + for sf := range info.subfamilies {
1362 + singleSubfamily = sf
1363 + }
1364 + // Add to contexts in the parent family (if any) or the subfamily
1365 + if len(info.contexts) > 0 {
1366 + for ctx := range info.contexts {
1367 + contextIssues[ctx] = append(contextIssues[ctx],
1368 + fmt.Sprintf("family '%s' has only one subfamily '%s'; possible cause: incomplete hierarchy; possible fix: either add more subfamilies or flatten the structure",
1369 + family, singleSubfamily))
1370 + }
1371 + } else {
1372 + // Add to contexts in the single subfamily
1373 + if subfamilyInfo, exists := families[singleSubfamily]; exists {
1374 + for ctx := range subfamilyInfo.contexts {
1375 + contextIssues[ctx] = append(contextIssues[ctx],
1376 + fmt.Sprintf("family '%s' has only one subfamily '%s'; possible cause: incomplete hierarchy; possible fix: either add more subfamilies or flatten the structure",
1377 + family, singleSubfamily))
1378 + }
1379 + }
1380 + }
1381 + }
1382 +
1383 + // Check for too many subfamilies
1384 + if subfamilyCount > 8 {
1385 + // Add to contexts in the parent family (if any) or all subfamily contexts
1386 + if len(info.contexts) > 0 {
1387 + for ctx := range info.contexts {
1388 + contextIssues[ctx] = append(contextIssues[ctx],
1389 + 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",
1390 + family, subfamilyCount))
1391 + }
1392 + } else {
1393 + // Add to one context from each subfamily
1394 + for subfamily := range info.subfamilies {
1395 + if subfamilyInfo, exists := families[subfamily]; exists {
1396 + for ctx := range subfamilyInfo.contexts {
1397 + contextIssues[ctx] = append(contextIssues[ctx],
1398 + 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",
1399 + family, subfamilyCount))
1400 + break // Only add to one context per subfamily
1401 + }
1402 + }
1403 + }
1404 + }
1405 + }
1406 + }
1407 + }
1408 +}
1409 +
1410 +// isSnakeCase checks if a string is in snake_case format
1411 +func isSnakeCase(s string) bool {
1412 + // Should be lowercase with underscores, dots allowed for contexts
1413 + for _, ch := range s {
1414 + if !((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '.') {
1415 + return false
1416 + }
1417 + }
1418 + return true
1419 +}
1420 +
1421 +// PrintDebugInfo prints additional debug information
1422 +func (da *DumpAnalyzer) PrintDebugInfo() {
1423 + da.mu.RLock()
1424 + defer da.mu.RUnlock()
1425 +
1426 + fmt.Println("\n\nDEBUG INFORMATION:")
1427 + fmt.Println(strings.Repeat("-", 80))
1428 +
1429 + for jobName, job := range da.jobs {
1430 + fmt.Printf("\n[%s] Chart Structure:\n", jobName)
1431 +
1432 + for _, ca := range job.Charts {
1433 + fmt.Printf("\nChart ID: %s\n", ca.Chart.ID)
1434 + fmt.Printf(" Context: %s\n", ca.Chart.Ctx)
1435 + fmt.Printf(" Title: %s\n", ca.Chart.Title)
1436 + fmt.Printf(" Units: %s\n", ca.Chart.Units)
1437 + fmt.Printf(" Family: %s\n", ca.Chart.Fam)
1438 + fmt.Printf(" Type: %s\n", ca.Chart.Type)
1439 + fmt.Printf(" Priority: %d\n", ca.Chart.Priority)
1440 +
1441 + if len(ca.Chart.Labels) > 0 {
1442 + fmt.Printf(" Labels:\n")
1443 + for _, label := range ca.Chart.Labels {
1444 + fmt.Printf(" %s: %s\n", label.Key, label.Value)
1445 + }
1446 + }
1447 +
1448 + fmt.Printf(" Dimensions:\n")
1449 + for _, dim := range ca.Chart.Dims {
1450 + status := "INACTIVE"
1451 + valueCount := 0
1452 + if ca.SeenDimensions[dim.ID] {
1453 + status = "ACTIVE"
1454 + valueCount = len(ca.CollectedValues[dim.ID])
1455 + }
1456 +
1457 + fmt.Printf(" %s (%s) - %s [%d values collected]\n",
1458 + dim.ID, dim.Name, status, valueCount)
1459 +
1460 + // Show sample values if collected
1461 + if valueCount > 0 {
1462 + samples := ca.CollectedValues[dim.ID]
1463 + if valueCount > 5 {
1464 + fmt.Printf(" Sample values: %v ... %v\n",
1465 + samples[:3], samples[valueCount-2:])
1466 + } else {
1467 + fmt.Printf(" Values: %v\n", samples)
1468 + }
1469 + }
1470 + }
1471 + }
1472 + }
1473 +}
src/go/plugin/agent/internal/naming/sanitize.go new
+24
@@ -0,0 +1,24 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package naming
4 +
5 +import "strings"
6 +
7 +var sanitizer = strings.NewReplacer(
8 + "/", "_",
9 + "\\", "_",
10 + " ", "_",
11 + ":", "_",
12 + "*", "_",
13 + "?", "_",
14 + "\"", "_",
15 + "<", "_",
16 + ">", "_",
17 + "|", "_",
18 +)
19 +
20 +// Sanitize returns a stable identifier-safe representation for names that can
21 +// flow into IDs, paths, and type identifiers.
22 +func Sanitize(name string) string {
23 + return sanitizer.Replace(name)
24 +}
src/go/plugin/agent/internal/terminal/terminal.go new
+15
@@ -0,0 +1,15 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package terminal
4 +
5 +import (
6 + "os"
7 +
8 + "github.com/mattn/go-isatty"
9 +)
10 +
11 +// IsTerminal reports whether plugin IO is attached to a terminal.
12 +// It checks stdout and stdin for consistent behavior across components.
13 +func IsTerminal() bool {
14 + return isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsTerminal(os.Stdin.Fd())
15 +}
src/go/plugin/agent/jobmgr/di.go
-5
@@ -4,12 +4,7 @@ package jobmgr
4
5 import (
6 "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
7 - "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
7 )
8
10 -type Vnodes interface {
11 - Lookup(key string) (*vnodes.VirtualNode, bool)
12 -}
13 -
9 // FunctionRegistry is an alias to functions.Registry for backward compatibility.
10 type FunctionRegistry = functions.Registry
src/go/plugin/agent/jobmgr/dyncfg.go
-7
@@ -6,15 +6,8 @@ import (
6 "strings"
7
8 "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
9 - "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
9 )
10
12 -// dyncfgConfigHandler wraps dyncfgConfig to convert functions.Function to dyncfg.Function.
13 -// This is needed because functions.Registry expects func(functions.Function).
14 -func (m *Manager) dyncfgConfigHandler(fn functions.Function) {
15 - m.dyncfgConfig(dyncfg.NewFunction(fn))
16 -}
17 -
11 func (m *Manager) dyncfgConfig(fn dyncfg.Function) {
12 if err := fn.ValidateArgs(2); err != nil {
13 m.Warningf("dyncfg: %v", err)
src/go/plugin/agent/jobmgr/dyncfg_collector.go
+17 -28
@@ -70,13 +70,13 @@ func (m *Manager) exposedLookupByName(module, job string) (*dyncfg.Entry[confgro
70 func (m *Manager) dyncfgCollectorExec(fn dyncfg.Function) {
71 switch fn.Command() {
72 case dyncfg.CommandUserconfig:
73 - m.dyncfgConfigUserconfig(fn)
73 + m.dyncfgCmdUserconfig(fn)
74 return
75 case dyncfg.CommandTest:
76 - m.dyncfgConfigTest(fn)
76 + m.dyncfgCmdTest(fn)
77 return
78 case dyncfg.CommandSchema:
79 - m.dyncfgConfigSchema(fn)
79 + m.dyncfgCmdSchema(fn)
80 return
81 }
82
@@ -89,18 +89,7 @@ func (m *Manager) dyncfgCollectorExec(fn dyncfg.Function) {
89
90 func (m *Manager) dyncfgCollectorSeqExec(fn dyncfg.Function) {
91 cmd := fn.Command()
92 -
93 - // Clear waitCfgOnOff before enable/disable (component concern, not handler's).
94 - if cmd == dyncfg.CommandEnable || cmd == dyncfg.CommandDisable {
95 - key, _, ok := m.collectorCb.ExtractKey(fn)
96 - if ok {
97 - if entry, ok := m.exposed.LookupByKey(key); ok {
98 - if entry.Cfg.FullName() == m.waitCfgOnOff {
99 - m.waitCfgOnOff = ""
100 - }
101 - }
102 - }
103 - }
92 + m.handler.SyncDecision(fn)
93
94 switch cmd {
95 case dyncfg.CommandAdd:
@@ -116,18 +105,18 @@ func (m *Manager) dyncfgCollectorSeqExec(fn dyncfg.Function) {
105 case dyncfg.CommandRestart:
106 m.handler.CmdRestart(fn)
107 case dyncfg.CommandTest:
119 - m.dyncfgConfigTest(fn)
108 + m.dyncfgCmdTest(fn)
109 case dyncfg.CommandSchema:
121 - m.dyncfgConfigSchema(fn)
110 + m.dyncfgCmdSchema(fn)
111 case dyncfg.CommandGet:
123 - m.dyncfgConfigGet(fn)
112 + m.dyncfgCmdGet(fn)
113 default:
114 m.Warningf("dyncfg: function '%s' command '%s' not implemented", fn.Fn().Name, cmd)
115 m.dyncfgApi.SendCodef(fn, 501, "Function '%s' command '%s' is not implemented.", fn.Fn().Name, cmd)
116 }
117 }
118
130 -func (m *Manager) dyncfgConfigUserconfig(fn dyncfg.Function) {
119 +func (m *Manager) dyncfgCmdUserconfig(fn dyncfg.Function) {
120 cmd := fn.Command()
121
122 id := fn.ID()
@@ -143,7 +132,7 @@ func (m *Manager) dyncfgConfigUserconfig(fn dyncfg.Function) {
132 return
133 }
134
146 - creator, ok := m.Modules.Lookup(mn)
135 + creator, ok := m.modules.Lookup(mn)
136 if !ok {
137 m.Warningf("dyncfg: %s: module %s not found", cmd, mn)
138 m.dyncfgApi.SendCodef(fn, 404, "The specified module '%s' is not registered.", mn)
@@ -166,7 +155,7 @@ func (m *Manager) dyncfgConfigUserconfig(fn dyncfg.Function) {
155 m.dyncfgApi.SendYAML(fn, string(bs))
156 }
157
169 -func (m *Manager) dyncfgConfigTest(fn dyncfg.Function) {
158 +func (m *Manager) dyncfgCmdTest(fn dyncfg.Function) {
159 cmd := fn.Command()
160
161 id := fn.ID()
@@ -190,7 +179,7 @@ func (m *Manager) dyncfgConfigTest(fn dyncfg.Function) {
179 return
180 }
181
193 - creator, ok := m.Modules.Lookup(mn)
182 + creator, ok := m.modules.Lookup(mn)
183 if !ok {
184 m.Warningf("dyncfg: %s: module %s not found", cmd, mn)
185 m.dyncfgApi.SendCodef(fn, 404, "The specified module '%s' is not registered.", mn)
@@ -204,7 +193,7 @@ func (m *Manager) dyncfgConfigTest(fn dyncfg.Function) {
193 }
194
195 if cfg.Vnode() != "" {
207 - if _, ok := m.Vnodes[cfg.Vnode()]; !ok {
196 + if _, ok := m.vnodes[cfg.Vnode()]; !ok {
197 m.Warningf("dyncfg: %s: module %s: vnode %s not found", cmd, mn, cfg.Vnode())
198 m.dyncfgApi.SendCodef(fn, 400, "The specified vnode '%s' is not registered.", cfg.Vnode())
199 return
@@ -246,7 +235,7 @@ func (m *Manager) dyncfgConfigTest(fn dyncfg.Function) {
235 m.dyncfgApi.SendCodef(fn, 200, "")
236 }
237
249 -func (m *Manager) dyncfgConfigSchema(fn dyncfg.Function) {
238 +func (m *Manager) dyncfgCmdSchema(fn dyncfg.Function) {
239 cmd := fn.Command()
240
241 id := fn.ID()
@@ -257,7 +246,7 @@ func (m *Manager) dyncfgConfigSchema(fn dyncfg.Function) {
246 return
247 }
248
260 - mod, ok := m.Modules.Lookup(mn)
249 + mod, ok := m.modules.Lookup(mn)
250 if !ok {
251 m.Warningf("dyncfg: %s: module %s not found", cmd, mn)
252 m.dyncfgApi.SendCodef(fn, 404, "The specified module '%s' is not registered.", mn)
@@ -275,7 +264,7 @@ func (m *Manager) dyncfgConfigSchema(fn dyncfg.Function) {
264 m.dyncfgApi.SendJSON(fn, mod.JobConfigSchema)
265 }
266
278 -func (m *Manager) dyncfgConfigGet(fn dyncfg.Function) {
267 +func (m *Manager) dyncfgCmdGet(fn dyncfg.Function) {
268 cmd := fn.Command()
269
270 id := fn.ID()
@@ -286,7 +275,7 @@ func (m *Manager) dyncfgConfigGet(fn dyncfg.Function) {
275 return
276 }
277
289 - creator, ok := m.Modules.Lookup(mn)
278 + creator, ok := m.modules.Lookup(mn)
279 if !ok {
280 m.Warningf("dyncfg: %s: module %s not found", cmd, mn)
281 m.dyncfgApi.SendCodef(fn, 404, "The specified module '%s' is not registered.", mn)
@@ -338,7 +327,7 @@ func (m *Manager) dyncfgSetConfigMeta(cfg confgroup.Config, module, name string,
327 cfg.SetSourceType("dyncfg")
328 cfg.SetModule(module)
329 cfg.SetName(name)
341 - if def, ok := m.ConfigDefaults.Lookup(module); ok {
330 + if def, ok := m.configDefaults.Lookup(module); ok {
331 cfg.ApplyDefaults(def)
332 }
333 }
src/go/plugin/agent/jobmgr/dyncfg_collector_test.go
+3 -3
@@ -30,8 +30,8 @@ func TestDyncfgConfigUserconfig_InvalidPayload_Returns400Only(t *testing.T) {
30 t.Run(name, func(t *testing.T) {
31 var buf bytes.Buffer
32
33 - mgr := New()
34 - mgr.Modules = prepareMockRegistry()
33 + mgr := New(Config{})
34 + mgr.modules = prepareMockRegistry()
35 mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
36
37 fn := dyncfg.NewFunction(functions.Function{
@@ -45,7 +45,7 @@ func TestDyncfgConfigUserconfig_InvalidPayload_Returns400Only(t *testing.T) {
45 },
46 })
47
48 - mgr.dyncfgConfigUserconfig(fn)
48 + mgr.dyncfgCmdUserconfig(fn)
49
50 out := buf.String()
51 assert.Equal(t, 1, strings.Count(out, "FUNCTION_RESULT_BEGIN bad-userconfig"))
src/go/plugin/agent/jobmgr/dyncfg_test.go
+1 -1
@@ -32,7 +32,7 @@ func TestDyncfgConfig_ShutdownDoesNotQueue(t *testing.T) {
32 t.Run(name, func(t *testing.T) {
33 var buf bytes.Buffer
34
35 - mgr := New()
35 + mgr := New(Config{})
36 mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
37
38 ctx, cancel := context.WithCancel(context.Background())
src/go/plugin/agent/jobmgr/dyncfg_vnode.go
+8 -8
@@ -117,7 +117,7 @@ func (m *Manager) dyncfgVnodeGet(fn dyncfg.Function) {
117 id := fn.ID()
118 name := strings.TrimPrefix(id, m.dyncfgVnodePrefixValue()+":")
119
120 - cfg, ok := m.Vnodes[name]
120 + cfg, ok := m.vnodes[name]
121 if !ok {
122 m.Warningf("dyncfg: %s: vnode %s not found", cmd, name)
123 m.dyncfgApi.SendCodef(fn, 404, "The specified vnode '%s' is not registered.", name)
@@ -172,13 +172,13 @@ func (m *Manager) dyncfgVnodeAdd(fn dyncfg.Function) {
172 return
173 }
174
175 - if orig, ok := m.Vnodes[name]; ok && orig.Equal(cfg) {
175 + if orig, ok := m.vnodes[name]; ok && orig.Equal(cfg) {
176 m.dyncfgApi.SendCodef(fn, 202, "")
177 m.dyncfgVnodeJobCreate(cfg, dyncfg.StatusRunning)
178 return
179 }
180
181 - m.Vnodes[name] = cfg
181 + m.vnodes[name] = cfg
182
183 m.runningJobs.forEach(func(_ string, job runtimeJob) {
184 if job.Vnode().Name == name {
@@ -196,7 +196,7 @@ func (m *Manager) dyncfgVnodeRemove(fn dyncfg.Function) {
196 id := fn.ID()
197 name := strings.TrimPrefix(id, m.dyncfgVnodePrefixValue()+":")
198
199 - vnode, ok := m.Vnodes[name]
199 + vnode, ok := m.vnodes[name]
200 if !ok {
201 m.Warningf("dyncfg: %s: vnode %s not found", cmd, name)
202 m.dyncfgApi.SendCodef(fn, 404, "The specified vnode '%s' is not registered.", name)
@@ -214,7 +214,7 @@ func (m *Manager) dyncfgVnodeRemove(fn dyncfg.Function) {
214 return
215 }
216
217 - delete(m.Vnodes, name)
217 + delete(m.vnodes, name)
218
219 m.dyncfgApi.ConfigDelete(id)
220 m.dyncfgApi.SendCodef(fn, 200, "")
@@ -265,7 +265,7 @@ func (m *Manager) dyncfgVnodeUpdate(fn dyncfg.Function) {
265 id := fn.ID()
266 name := strings.TrimPrefix(id, m.dyncfgVnodePrefixValue()+":")
267
268 - orig, ok := m.Vnodes[name]
268 + orig, ok := m.vnodes[name]
269 if !ok {
270 m.Warningf("dyncfg: %s: vnode %s not found", cmd, name)
271 m.dyncfgApi.SendCodef(fn, 404, "The specified vnode '%s' is not registered.", name)
@@ -292,7 +292,7 @@ func (m *Manager) dyncfgVnodeUpdate(fn dyncfg.Function) {
292 return
293 }
294
295 - m.Vnodes[name] = cfg
295 + m.vnodes[name] = cfg
296
297 m.runningJobs.forEach(func(_ string, job runtimeJob) {
298 if job.Vnode().Name == name {
@@ -332,7 +332,7 @@ func (m *Manager) dyncfgVnodeAffectedJobs(vnode string) string {
332 }
333
334 func (m *Manager) verifyVnodeUnique(newCfg *vnodes.VirtualNode) error {
335 - for _, cfg := range m.Vnodes {
335 + for _, cfg := range m.vnodes {
336 if cfg.Name == newCfg.Name {
337 continue
338 }
src/go/plugin/agent/jobmgr/filestatus.go
+5 -4
@@ -12,6 +12,7 @@ import (
12 "sync"
13
14 "github.com/netdata/netdata/go/plugins/pkg/executable"
15 + "github.com/netdata/netdata/go/plugins/plugin/agent/internal/terminal"
16 "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
17 "github.com/netdata/netdata/go/plugins/plugin/framework/filepersister"
18 )
@@ -24,11 +25,11 @@ func statusFileName(dir string) string {
25 func (m *Manager) loadFileStatus() {
26 m.fileStatus = newFileStatus()
27
27 - if isTerminal || m.VarLibDir == "" {
28 + if terminal.IsTerminal() || m.varLibDir == "" {
29 return
30 }
31
31 - s, err := loadFileStatus(statusFileName(m.VarLibDir))
32 + s, err := loadFileStatus(statusFileName(m.varLibDir))
33 if err != nil {
34 m.Warningf("failed to load state file: %v", err)
35 return
@@ -37,11 +38,11 @@ func (m *Manager) loadFileStatus() {
38 }
39
40 func (m *Manager) runFileStatusPersistence() {
40 - if m.VarLibDir == "" {
41 + if m.varLibDir == "" {
42 return
43 }
44
44 - p := filepersister.New(statusFileName(m.VarLibDir))
45 + p := filepersister.New(statusFileName(m.varLibDir))
46
47 p.Run(m.ctx, m.fileStatus)
48 }
src/go/plugin/agent/jobmgr/funcshandler.go
+2 -2
@@ -313,8 +313,8 @@ func (m *Manager) respondJSON(fn functions.Function, resp map[string]any) {
313 }
314 }
315
316 - if m.FunctionJSONWriter != nil {
317 - m.FunctionJSONWriter(data, code)
316 + if m.functionJSONWriter != nil {
317 + m.functionJSONWriter(data, code)
318 return
319 }
320
src/go/plugin/agent/jobmgr/funcshandler_test.go
+66 -62
@@ -106,71 +106,75 @@ func TestExtractParamValues(t *testing.T) {
106 }
107 }
108
109 -func TestBuildAcceptedParams(t *testing.T) {
110 - sortDir := funcapi.FieldSortDescending
111 - methodParams := []funcapi.ParamConfig{
112 - {ID: "__sort", Selection: funcapi.ParamSelect, Options: []funcapi.ParamOption{{ID: "calls", Name: "Calls", Sort: &sortDir}}},
113 - {ID: "db"},
114 - {ID: "extra"},
115 - }
116 -
117 - result := buildAcceptedParams(methodParams)
118 - assert.Equal(t, []string{"__job", "__sort", "db", "extra"}, result)
119 -}
120 -
121 -// TestBuildRequiredParams_TypeSelect verifies that all selectors use type "select" (single-select)
122 -// This is critical because type "multiselect" would show checkboxes instead of dropdowns
123 -func TestBuildRequiredParams_TypeSelect(t *testing.T) {
124 - // Setup a minimal manager with test data
125 - r := newModuleFuncRegistry()
126 - r.registerModule("postgres", collectorapi.Creator{
127 - Methods: func() []funcapi.MethodConfig {
128 - return []funcapi.MethodConfig{{
129 - ID: "top-queries",
130 - Name: "Top Queries",
131 - }}
109 +func TestBuildParams(t *testing.T) {
110 + tests := map[string]struct {
111 + run func(t *testing.T)
112 + }{
113 + "build accepted params": {
114 + run: func(t *testing.T) {
115 + sortDir := funcapi.FieldSortDescending
116 + methodParams := []funcapi.ParamConfig{
117 + {ID: "__sort", Selection: funcapi.ParamSelect, Options: []funcapi.ParamOption{{ID: "calls", Name: "Calls", Sort: &sortDir}}},
118 + {ID: "db"},
119 + {ID: "extra"},
120 + }
121 +
122 + result := buildAcceptedParams(methodParams)
123 + assert.Equal(t, []string{"__job", "__sort", "db", "extra"}, result)
124 + },
125 },
133 - })
134 - r.addJob("postgres", "master-db", newTestModuleFuncsJob("master-db"))
135 -
136 - // Create a manager with the registry
137 - mgr := &Manager{moduleFuncs: r}
138 -
139 - // Get required_params through the public method
140 - methodParams := []funcapi.ParamConfig{
141 - {
142 - ID: "__sort",
143 - Name: "Filter By",
144 - Selection: funcapi.ParamSelect,
145 - UniqueView: true,
146 - Options: []funcapi.ParamOption{
147 - {ID: "total_time", Name: "By Total Time", Default: true},
126 + "build required params uses select type": {
127 + run: func(t *testing.T) {
128 + // Setup a minimal manager with test data.
129 + r := newModuleFuncRegistry()
130 + r.registerModule("postgres", collectorapi.Creator{
131 + Methods: func() []funcapi.MethodConfig {
132 + return []funcapi.MethodConfig{{
133 + ID: "top-queries",
134 + Name: "Top Queries",
135 + }}
136 + },
137 + })
138 + r.addJob("postgres", "master-db", newTestModuleFuncsJob("master-db"))
139 +
140 + mgr := &Manager{moduleFuncs: r}
141 + methodParams := []funcapi.ParamConfig{
142 + {
143 + ID: "__sort",
144 + Name: "Filter By",
145 + Selection: funcapi.ParamSelect,
146 + UniqueView: true,
147 + Options: []funcapi.ParamOption{
148 + {ID: "total_time", Name: "By Total Time", Default: true},
149 + },
150 + },
151 + }
152 + params := mgr.buildRequiredParams("postgres", methodParams)
153 +
154 + assert.Len(t, params, 2, "should have 2 required params: __job, __sort")
155 + for _, param := range params {
156 + paramType, ok := param["type"]
157 + assert.True(t, ok, "param should have type field")
158 + assert.Equal(t, "select", paramType, "param type must be 'select' for single-select, not 'multiselect'")
159 +
160 + assert.Contains(t, param, "id", "param should have id")
161 + assert.Contains(t, param, "name", "param should have name")
162 + assert.Contains(t, param, "options", "param should have options")
163 + assert.Contains(t, param, "unique_view", "param should have unique_view")
164 +
165 + uniqueView, _ := param["unique_view"].(bool)
166 + assert.True(t, uniqueView, "unique_view should be true")
167 + }
168 +
169 + assert.Equal(t, "__job", params[0]["id"])
170 + assert.Equal(t, "__sort", params[1]["id"])
171 },
172 },
173 }
151 - params := mgr.buildRequiredParams("postgres", methodParams)
152 -
153 - // Verify structure
154 - assert.Len(t, params, 2, "should have 2 required params: __job, __sort")
155 -
156 - // All params should have type: "select" (NOT "multiselect")
157 - for _, param := range params {
158 - paramType, ok := param["type"]
159 - assert.True(t, ok, "param should have type field")
160 - assert.Equal(t, "select", paramType, "param type must be 'select' for single-select, not 'multiselect'")
161 -
162 - // Verify required fields exist
163 - assert.Contains(t, param, "id", "param should have id")
164 - assert.Contains(t, param, "name", "param should have name")
165 - assert.Contains(t, param, "options", "param should have options")
166 - assert.Contains(t, param, "unique_view", "param should have unique_view")
167 -
168 - // Verify unique_view is true
169 - uniqueView, _ := param["unique_view"].(bool)
170 - assert.True(t, uniqueView, "unique_view should be true")
171 - }
174
173 - // Verify specific param IDs
174 - assert.Equal(t, "__job", params[0]["id"])
175 - assert.Equal(t, "__sort", params[1]["id"])
175 + for name, tc := range tests {
176 + t.Run(name, func(t *testing.T) {
177 + tc.run(t)
178 + })
179 + }
180 }
src/go/plugin/agent/jobmgr/manager.go
+101 -82
@@ -10,17 +10,17 @@ import (
10 "os"
11 "path/filepath"
12 "slices"
13 - "strings"
13 "sync"
14 "time"
15
17 - "github.com/mattn/go-isatty"
16 "github.com/netdata/netdata/go/plugins/logger"
17 "github.com/netdata/netdata/go/plugins/pkg/executable"
18 "github.com/netdata/netdata/go/plugins/pkg/funcapi"
19 "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
20 "github.com/netdata/netdata/go/plugins/pkg/safewriter"
21 "github.com/netdata/netdata/go/plugins/pkg/ticker"
22 + "github.com/netdata/netdata/go/plugins/plugin/agent/internal/naming"
23 + "github.com/netdata/netdata/go/plugins/plugin/agent/internal/terminal"
24 "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
25 "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
26 "github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
@@ -31,20 +31,58 @@ import (
31 "gopkg.in/yaml.v2"
32 )
33
34 -var isTerminal = isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsTerminal(os.Stdin.Fd())
34 +type Config struct {
35 + PluginName string
36 + Out io.Writer
37 + Modules collectorapi.Registry
38 + RunJob []string
39 + ConfigDefaults confgroup.Registry
40 + VarLibDir string
41 + FnReg FunctionRegistry
42 + Vnodes map[string]*vnodes.VirtualNode
43 + DumpMode bool
44 + DumpAnalyzer jobruntime.DumpAnalyzer
45 + DumpDataDir string
46 + FunctionJSONWriter func(payload []byte, code int)
47 + RuntimeService runtimecomp.Service
48 +}
49
36 -func New() *Manager {
50 +func New(cfg Config) *Manager {
51 seen := dyncfg.NewSeenCache[confgroup.Config]()
52 exposed := dyncfg.NewExposedCache[confgroup.Config]()
53 api := dyncfg.NewResponder(netdataapi.New(safewriter.Stdout))
54 +
55 + out := cfg.Out
56 + if out == nil {
57 + out = io.Discard
58 + }
59 + fnReg := cfg.FnReg
60 + if fnReg == nil {
61 + fnReg = noop{}
62 + }
63 + vnodesReg := cfg.Vnodes
64 + if vnodesReg == nil {
65 + vnodesReg = make(map[string]*vnodes.VirtualNode)
66 + }
67 +
68 mgr := &Manager{
69 Logger: logger.New().With(
70 slog.String("component", "job manager"),
71 ),
44 - Out: io.Discard,
45 - FnReg: noop{},
46 -
47 - Vnodes: make(map[string]*vnodes.VirtualNode),
72 + pluginName: cfg.PluginName,
73 + out: out,
74 + modules: cfg.Modules,
75 + runJob: cfg.RunJob,
76 + configDefaults: cfg.ConfigDefaults,
77 + varLibDir: cfg.VarLibDir,
78 + fnReg: fnReg,
79 + vnodes: vnodesReg,
80 +
81 + dumpMode: cfg.DumpMode,
82 + dumpAnalyzer: cfg.DumpAnalyzer,
83 + dumpDataDir: cfg.DumpDataDir,
84 + functionJSONWriter: cfg.FunctionJSONWriter,
85 + runtimeService: cfg.RuntimeService,
86
87 moduleFuncs: newModuleFuncRegistry(),
88 discoveredConfigs: newDiscoveredConfigsCache(),
@@ -67,6 +105,9 @@ func New() *Manager {
105 Seen: seen,
106 Exposed: exposed,
107 Callbacks: mgr.collectorCb,
108 + WaitKey: func(cfg confgroup.Config) string {
109 + return cfg.FullName()
110 + },
111
112 Path: fmt.Sprintf(dyncfgCollectorPath, executable.Name),
113 EnableFailCode: 200,
@@ -88,28 +129,25 @@ func New() *Manager {
129
130 // SetDyncfgResponder allows overriding the default responder (e.g., to silence output in CLI mode).
131 func (m *Manager) SetDyncfgResponder(responder *dyncfg.Responder) {
91 - if responder != nil {
92 - m.dyncfgApi = responder
93 - m.handler.SetAPI(responder)
94 - }
132 + dyncfg.BindResponder(&m.dyncfgApi, m.handler, responder)
133 }
134
135 type Manager struct {
136 *logger.Logger
137
100 - PluginName string
101 - Out io.Writer
102 - Modules collectorapi.Registry
103 - RunJob []string
104 - ConfigDefaults confgroup.Registry
105 - VarLibDir string
106 - FnReg FunctionRegistry
107 - Vnodes map[string]*vnodes.VirtualNode
138 + pluginName string
139 + out io.Writer
140 + modules collectorapi.Registry
141 + runJob []string
142 + configDefaults confgroup.Registry
143 + varLibDir string
144 + fnReg FunctionRegistry
145 + vnodes map[string]*vnodes.VirtualNode
146
147 // Dump mode
110 - DumpMode bool
111 - DumpAnalyzer interface{} // Will be *agent.DumpAnalyzer but avoid circular dependency
112 - DumpDataDir string
148 + dumpMode bool
149 + dumpAnalyzer jobruntime.DumpAnalyzer
150 + dumpDataDir string
151
152 fileStatus *fileStatus
153 moduleFuncs *moduleFuncRegistry
@@ -123,23 +161,20 @@ type Manager struct {
161 handler *dyncfg.Handler[confgroup.Config]
162 collectorCb *collectorCallbacks
163
126 - ctx context.Context
127 - started chan struct{}
128 - //api dyncfgAPI
164 + ctx context.Context
165 + started chan struct{}
166 addCh chan confgroup.Config
167 rmCh chan confgroup.Config
168 dyncfgCh chan dyncfg.Function
169
133 - waitCfgOnOff string // block processing of discovered configs until "enable"/"disable" is received from Netdata
134 -
170 dyncfgApi *dyncfg.Responder
171
172 // FunctionJSONWriter, when set, bypasses Netdata protocol output and writes raw JSON.
138 - FunctionJSONWriter func(payload []byte, code int)
173 + functionJSONWriter func(payload []byte, code int)
174
175 // RuntimeService is an optional runtime/internal metrics registration seam.
176 // When set, V2 jobs may register per-job runtime components.
142 - RuntimeService runtimecomp.Service
177 + runtimeService runtimecomp.Service
178 }
179
180 func (m *Manager) Run(ctx context.Context, in chan []*confgroup.Group) {
@@ -147,16 +182,16 @@ func (m *Manager) Run(ctx context.Context, in chan []*confgroup.Group) {
182 defer func() { m.cleanup(); m.Info("instance is stopped") }()
183 m.ctx = ctx
184
150 - m.FnReg.RegisterPrefix("config", m.dyncfgCollectorPrefixValue(), m.dyncfgConfigHandler)
151 - m.FnReg.RegisterPrefix("config", m.dyncfgVnodePrefixValue(), m.dyncfgConfigHandler)
185 + m.fnReg.RegisterPrefix("config", m.dyncfgCollectorPrefixValue(), dyncfg.WrapHandler(m.dyncfgConfig))
186 + m.fnReg.RegisterPrefix("config", m.dyncfgVnodePrefixValue(), dyncfg.WrapHandler(m.dyncfgConfig))
187
188 m.dyncfgVnodeModuleCreate()
189
155 - for _, cfg := range m.Vnodes {
190 + for _, cfg := range m.vnodes {
191 m.dyncfgVnodeJobCreate(cfg, dyncfg.StatusRunning)
192 }
193
159 - for name, creator := range m.Modules {
194 + for name, creator := range m.modules {
195 m.dyncfgCollectorModuleCreate(name)
196
197 // Register module if it provides static methods OR per-job methods
@@ -173,7 +208,7 @@ func (m *Manager) Run(ctx context.Context, in chan []*confgroup.Group) {
208 continue
209 }
210 funcName := fmt.Sprintf("%s:%s", name, method.ID)
176 - m.FnReg.Register(funcName, m.makeMethodFuncHandler(name, method.ID))
211 + m.fnReg.Register(funcName, m.makeMethodFuncHandler(name, method.ID))
212
213 // Notify Netdata about this function so it appears in the functions API
214 help := method.Help
@@ -261,9 +296,9 @@ func (m *Manager) runProcessConfGroups(in chan []*confgroup.Group) {
296 for _, gr := range groups {
297 a, r := m.discoveredConfigs.add(gr)
298 m.Debugf("received configs: %d/+%d/-%d ('%s')", len(gr.Configs), len(a), len(r), gr.Source)
264 - if len(m.RunJob) > 0 {
299 + if len(m.runJob) > 0 {
300 a = slices.DeleteFunc(a, func(config confgroup.Config) bool {
266 - return !slices.ContainsFunc(m.RunJob, func(name string) bool { return config.Name() == name })
301 + return !slices.ContainsFunc(m.runJob, func(name string) bool { return config.Name() == name })
302 })
303 }
304 sendConfigs(m.ctx, m.rmCh, r...)
@@ -275,7 +310,7 @@ func (m *Manager) runProcessConfGroups(in chan []*confgroup.Group) {
310
311 func (m *Manager) run() {
312 for {
278 - if m.waitCfgOnOff != "" {
313 + if m.handler.WaitingForDecision() {
314 select {
315 case <-m.ctx.Done():
316 return
@@ -298,20 +333,17 @@ func (m *Manager) run() {
333 }
334
335 func (m *Manager) addConfig(cfg confgroup.Config) {
301 - if _, ok := m.Modules.Lookup(cfg.Module()); !ok {
336 + if _, ok := m.modules.Lookup(cfg.Module()); !ok {
337 return
338 }
339
340 m.retryingTasks.remove(cfg)
341
307 - if _, ok := m.seen.Lookup(cfg); !ok {
308 - m.seen.Add(cfg)
309 - }
342 + m.handler.RememberDiscoveredConfig(cfg)
343
344 entry, ok := m.exposed.LookupByKey(cfg.ExposedKey())
345 if !ok {
313 - entry = &dyncfg.Entry[confgroup.Config]{Cfg: cfg, Status: dyncfg.StatusAccepted}
314 - m.exposed.Add(entry)
346 + entry = m.handler.AddDiscoveredConfig(cfg, dyncfg.StatusAccepted)
347 } else {
348 sp, ep := cfg.SourceTypePriority(), entry.Cfg.SourceTypePriority()
349 if ep > sp || (ep == sp && entry.Status == dyncfg.StatusRunning) {
@@ -321,33 +353,25 @@ func (m *Manager) addConfig(cfg confgroup.Config) {
353 m.stopRunningJob(entry.Cfg.FullName())
354 m.fileStatus.remove(entry.Cfg)
355 }
324 - entry = &dyncfg.Entry[confgroup.Config]{Cfg: cfg, Status: dyncfg.StatusAccepted}
325 - m.exposed.Add(entry) // replace existing exposed
356 + entry = m.handler.AddDiscoveredConfig(cfg, dyncfg.StatusAccepted) // replace existing exposed
357 }
358
359 m.handler.NotifyJobCreate(entry.Cfg, entry.Status)
360
330 - if isTerminal || m.PluginName == "nodyncfg" { // FIXME: quick fix of TestAgent_Run (agent_test.go)
361 + if terminal.IsTerminal() || m.pluginName == "nodyncfg" { // FIXME: quick fix of TestAgent_Run (agent_test.go)
362 m.handler.CmdEnable(dyncfg.NewFunction(functions.Function{Args: []string{m.dyncfgJobID(entry.Cfg), "enable"}}))
363 } else {
333 - m.waitCfgOnOff = entry.Cfg.FullName()
364 + m.handler.WaitForDecision(entry.Cfg)
365 }
366 }
367
368 func (m *Manager) removeConfig(cfg confgroup.Config) {
369 m.retryingTasks.remove(cfg)
370
340 - if _, ok := m.seen.Lookup(cfg); !ok {
341 - return
342 - }
343 - m.seen.Remove(cfg)
344 -
345 - entry, ok := m.exposed.LookupByKey(cfg.ExposedKey())
346 - if !ok || cfg.UID() != entry.Cfg.UID() {
371 + entry, ok := m.handler.RemoveDiscoveredConfig(cfg)
372 + if !ok {
373 return
374 }
349 -
350 - m.exposed.Remove(cfg)
375 m.stopRunningJob(cfg.FullName())
376 m.fileStatus.remove(cfg)
377
@@ -385,7 +409,7 @@ func (m *Manager) startRunningJob(job runtimeJob) {
409 m.moduleFuncs.addJob(job.ModuleName(), job.Name(), job)
410
411 // Register job-specific methods if module provides JobMethods callback
388 - creator, ok := m.Modules.Lookup(job.ModuleName())
412 + creator, ok := m.modules.Lookup(job.ModuleName())
413 if !ok || creator.JobMethods == nil {
414 return
415 }
@@ -413,18 +437,18 @@ func (m *Manager) stopRunningJob(name string) {
437 }
438
439 func (m *Manager) cleanup() {
416 - m.FnReg.UnregisterPrefix("config", m.dyncfgCollectorPrefixValue())
417 - m.FnReg.UnregisterPrefix("config", m.dyncfgVnodePrefixValue())
440 + m.fnReg.UnregisterPrefix("config", m.dyncfgCollectorPrefixValue())
441 + m.fnReg.UnregisterPrefix("config", m.dyncfgVnodePrefixValue())
442
443 // Unregister module functions
420 - for name, creator := range m.Modules {
444 + for name, creator := range m.modules {
445 if creator.Methods != nil {
446 for _, method := range creator.Methods() {
447 if method.ID == "" {
448 continue
449 }
450 funcName := fmt.Sprintf("%s:%s", name, method.ID)
427 - m.FnReg.Unregister(funcName)
451 + m.fnReg.Unregister(funcName)
452 }
453 }
454 }
@@ -448,7 +472,7 @@ func (m *Manager) registerJobMethods(job collectorapi.RuntimeJob, methods []func
472 funcName := fmt.Sprintf("%s:%s", job.ModuleName(), method.ID)
473
474 // Register Go handler for this function
451 - m.FnReg.Register(funcName, m.makeJobMethodFuncHandler(job.ModuleName(), job.Name(), method.ID))
475 + m.fnReg.Register(funcName, m.makeJobMethodFuncHandler(job.ModuleName(), job.Name(), method.ID))
476
477 // Notify Netdata about this function
478 help := method.Help
@@ -494,7 +518,7 @@ func (m *Manager) unregisterJobMethods(job collectorapi.RuntimeJob) {
518 funcName := fmt.Sprintf("%s:%s", job.ModuleName(), method.ID)
519
520 // Unregister Go handler
497 - m.FnReg.Unregister(funcName)
521 + m.fnReg.Unregister(funcName)
522
523 // Notify Netdata to remove function (no-op until Netdata supports it)
524 m.dyncfgApi.FunctionRemove(funcName)
@@ -507,7 +531,7 @@ func (m *Manager) unregisterJobMethods(job collectorapi.RuntimeJob) {
531 }
532
533 func (m *Manager) createCollectorJob(cfg confgroup.Config) (runtimeJob, error) {
510 - creator, ok := m.Modules[cfg.Module()]
534 + creator, ok := m.modules[cfg.Module()]
535 if !ok {
536 return nil, fmt.Errorf("can not find %s module", cfg.Module())
537 }
@@ -524,7 +548,7 @@ func (m *Manager) createCollectorJob(cfg confgroup.Config) (runtimeJob, error) {
548 var vnode *vnodes.VirtualNode
549
550 if cfg.Vnode() != "" {
527 - n, ok := m.Vnodes[cfg.Vnode()]
551 + n, ok := m.vnodes[cfg.Vnode()]
552 if !ok || n == nil {
553 return nil, fmt.Errorf("vnode '%s' is not found", cfg.Vnode())
554 }
@@ -534,13 +558,13 @@ func (m *Manager) createCollectorJob(cfg confgroup.Config) (runtimeJob, error) {
558 m.Debugf("creating %s[%s] job, config: %v", cfg.Module(), cfg.Name(), cfg)
559
560 var jobDumpDir string
537 - if m.DumpDataDir != "" {
538 - jobDumpDir = filepath.Join(m.DumpDataDir, sanitizeName(cfg.Module()), sanitizeName(cfg.Name()))
561 + if m.dumpDataDir != "" {
562 + jobDumpDir = filepath.Join(m.dumpDataDir, naming.Sanitize(cfg.Module()), naming.Sanitize(cfg.Name()))
563 if err := os.MkdirAll(jobDumpDir, 0o755); err != nil {
564 return nil, fmt.Errorf("creating dump directory: %w", err)
565 }
542 - if analyzer, ok := m.DumpAnalyzer.(interface{ RegisterJob(string, string, string) }); ok {
543 - analyzer.RegisterJob(cfg.Name(), cfg.Module(), jobDumpDir)
566 + if m.dumpAnalyzer != nil {
567 + m.dumpAnalyzer.RegisterJob(cfg.Name(), cfg.Module(), jobDumpDir)
568 }
569 }
570
@@ -560,7 +584,7 @@ func (m *Manager) createCollectorJob(cfg confgroup.Config) (runtimeJob, error) {
584 }
585
586 jobCfg := jobruntime.JobV2Config{
563 - PluginName: m.PluginName,
587 + PluginName: m.pluginName,
588 Name: cfg.Name(),
589 ModuleName: cfg.Module(),
590 FullName: cfg.FullName(),
@@ -568,10 +592,10 @@ func (m *Manager) createCollectorJob(cfg confgroup.Config) (runtimeJob, error) {
592 AutoDetectEvery: cfg.AutoDetectionRetry(),
593 IsStock: cfg.SourceType() == "stock",
594 Labels: makeLabels(cfg),
571 - Out: m.Out,
595 + Out: m.out,
596 Module: mod,
597 FunctionOnly: functionOnly,
574 - RuntimeService: m.RuntimeService,
598 + RuntimeService: m.runtimeService,
599 }
600 if vnode != nil {
601 jobCfg.Vnode = *vnode.Copy()
@@ -596,7 +620,7 @@ func (m *Manager) createCollectorJob(cfg confgroup.Config) (runtimeJob, error) {
620 }
621
622 jobCfg := jobruntime.JobConfig{
599 - PluginName: m.PluginName,
623 + PluginName: m.pluginName,
624 Name: cfg.Name(),
625 ModuleName: cfg.Module(),
626 FullName: cfg.FullName(),
@@ -606,9 +630,9 @@ func (m *Manager) createCollectorJob(cfg confgroup.Config) (runtimeJob, error) {
630 Labels: makeLabels(cfg),
631 IsStock: cfg.SourceType() == "stock",
632 Module: mod,
609 - Out: m.Out,
610 - DumpMode: m.DumpMode,
611 - DumpAnalyzer: m.DumpAnalyzer,
633 + Out: m.out,
634 + DumpMode: m.dumpMode,
635 + DumpAnalyzer: m.dumpAnalyzer,
636 FunctionOnly: functionOnly,
637 }
638
@@ -621,11 +645,6 @@ func (m *Manager) createCollectorJob(cfg confgroup.Config) (runtimeJob, error) {
645 return job, nil
646 }
647
624 -func sanitizeName(name string) string {
625 - replacer := strings.NewReplacer("/", "_", "\\", "_", " ", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_")
626 - return replacer.Replace(name)
627 -}
628 -
648 func runRetryTask(ctx context.Context, out chan<- confgroup.Config, cfg confgroup.Config) {
649 t := time.NewTimer(time.Second * time.Duration(cfg.AutoDetectionRetry()))
650 defer t.Stop()
src/go/plugin/agent/jobmgr/manager_process_test.go
+1 -1
@@ -30,7 +30,7 @@ func TestRunProcessConfGroups_ChannelCloseDoesNotSpin(t *testing.T) {
30
31 for name, tc := range tests {
32 t.Run(name, func(t *testing.T) {
33 - mgr := New()
33 + mgr := New(Config{})
34 ctx, cancel := context.WithCancel(context.Background())
35 t.Cleanup(cancel)
36 mgr.ctx = ctx
src/go/plugin/agent/jobmgr/manager_v2_test.go
+2 -2
@@ -103,8 +103,8 @@ func TestManagerCreateCollectorJobV2Branching(t *testing.T) {
103
104 for name, tc := range tests {
105 t.Run(name, func(t *testing.T) {
106 - mgr := New()
107 - mgr.Modules = collectorapi.Registry{
106 + mgr := New(Config{})
107 + mgr.modules = collectorapi.Registry{
108 "testmod": tc.creator,
109 }
110 cfg := prepareUserCfg("testmod", "job1")
src/go/plugin/agent/jobmgr/modulefuncs_test.go
+108 -121
@@ -53,141 +53,128 @@ func TestModuleFuncRegistry_RegisterModule(t *testing.T) {
53 }
54 }
55
56 -func TestModuleFuncRegistry_AddRemoveJob(t *testing.T) {
57 - r := newModuleFuncRegistry()
58 - r.registerModule("postgres", collectorapi.Creator{})
59 -
60 - // Create test jobs
61 - job1 := newTestModuleFuncsJob("job1")
62 - job2 := newTestModuleFuncsJob("job2")
63 -
64 - // Add jobs
65 - r.addJob("postgres", "job1", job1)
66 - r.addJob("postgres", "job2", job2)
67 -
68 - // Verify jobs are retrievable
69 - names := r.getJobNames("postgres")
70 - assert.ElementsMatch(t, []string{"job1", "job2"}, names)
71 -
72 - got1, ok := r.getJob("postgres", "job1")
73 - assert.True(t, ok)
74 - assert.Equal(t, job1, got1)
75 -
76 - // Remove job
77 - r.removeJob("postgres", "job1")
78 -
79 - names = r.getJobNames("postgres")
80 - assert.ElementsMatch(t, []string{"job2"}, names)
81 -
82 - _, ok = r.getJob("postgres", "job1")
83 - assert.False(t, ok)
84 -}
85 -
86 -func TestModuleFuncRegistry_JobReplacement(t *testing.T) {
87 - r := newModuleFuncRegistry()
88 - r.registerModule("postgres", collectorapi.Creator{})
89 -
90 - job1 := newTestModuleFuncsJob("master")
91 - job2 := newTestModuleFuncsJob("master") // Same name, different instance
92 -
93 - // Add first job
94 - r.addJob("postgres", "master", job1)
95 - _, gen1 := r.getJobWithGeneration("postgres", "master")
96 - assert.Equal(t, uint64(1), gen1)
97 -
98 - // Replace with second job
99 - r.addJob("postgres", "master", job2)
100 - got, gen2 := r.getJobWithGeneration("postgres", "master")
101 - assert.Equal(t, uint64(2), gen2) // Generation incremented
102 - assert.Equal(t, job2, got) // New job returned
103 -}
56 +func TestModuleFuncRegistry_Operations(t *testing.T) {
57 + tests := map[string]struct {
58 + run func(t *testing.T, r *moduleFuncRegistry)
59 + }{
60 + "add/remove job": {
61 + run: func(t *testing.T, r *moduleFuncRegistry) {
62 + r.registerModule("postgres", collectorapi.Creator{})
63
105 -func TestModuleFuncRegistry_GenerationVerification(t *testing.T) {
106 - r := newModuleFuncRegistry()
107 - r.registerModule("postgres", collectorapi.Creator{})
64 + job1 := newTestModuleFuncsJob("job1")
65 + job2 := newTestModuleFuncsJob("job2")
66
109 - job := newTestModuleFuncsJob("master")
67 + r.addJob("postgres", "job1", job1)
68 + r.addJob("postgres", "job2", job2)
69
111 - r.addJob("postgres", "master", job)
112 - _, gen := r.getJobWithGeneration("postgres", "master")
70 + names := r.getJobNames("postgres")
71 + assert.ElementsMatch(t, []string{"job1", "job2"}, names)
72
114 - // Note: verifyJobGeneration checks BOTH generation AND IsRunning()
115 - // Since our test job isn't running, verification should fail
116 - // This is actually correct behavior - it catches stopped jobs
73 + got1, ok := r.getJob("postgres", "job1")
74 + assert.True(t, ok)
75 + assert.Equal(t, job1, got1)
76
118 - // Verify with wrong generation - should fail
119 - assert.False(t, r.verifyJobGeneration("postgres", "master", gen+1))
77 + r.removeJob("postgres", "job1")
78
121 - // Remove job and verify - should fail
122 - r.removeJob("postgres", "master")
123 - assert.False(t, r.verifyJobGeneration("postgres", "master", gen))
124 -}
125 -
126 -func TestModuleFuncRegistry_GetMethods(t *testing.T) {
127 - r := newModuleFuncRegistry()
79 + names = r.getJobNames("postgres")
80 + assert.ElementsMatch(t, []string{"job2"}, names)
81
129 - expectedMethods := []funcapi.MethodConfig{
130 - {ID: "top-queries", Name: "Top Queries"},
131 - }
132 -
133 - r.registerModule("postgres", collectorapi.Creator{
134 - Methods: func() []funcapi.MethodConfig {
135 - return expectedMethods
82 + _, ok = r.getJob("postgres", "job1")
83 + assert.False(t, ok)
84 + },
85 },
137 - })
138 -
139 - methods := r.getMethods("postgres")
140 - assert.Equal(t, expectedMethods, methods)
141 -
142 - // Non-existent module
143 - assert.Nil(t, r.getMethods("nonexistent"))
144 -}
145 -
146 -func TestModuleFuncRegistry_GetJobNames_Sorted(t *testing.T) {
147 - r := newModuleFuncRegistry()
148 - r.registerModule("postgres", collectorapi.Creator{})
149 -
150 - // Add jobs in random order
151 - r.addJob("postgres", "zebra-db", newTestModuleFuncsJob("zebra"))
152 - r.addJob("postgres", "alpha-db", newTestModuleFuncsJob("alpha"))
153 - r.addJob("postgres", "middle-db", newTestModuleFuncsJob("middle"))
154 -
155 - names := r.getJobNames("postgres")
156 -
157 - // Should be sorted alphabetically
158 - assert.Equal(t, []string{"alpha-db", "middle-db", "zebra-db"}, names)
159 -}
160 -
161 -func TestModuleFuncRegistry_UnregisteredModule(t *testing.T) {
162 - r := newModuleFuncRegistry()
163 -
164 - // Operations on unregistered module should be no-ops
165 - r.addJob("nonexistent", "job1", newTestModuleFuncsJob("job1"))
166 - r.removeJob("nonexistent", "job1")
86 + "job replacement increments generation": {
87 + run: func(t *testing.T, r *moduleFuncRegistry) {
88 + r.registerModule("postgres", collectorapi.Creator{})
89 +
90 + job1 := newTestModuleFuncsJob("master")
91 + job2 := newTestModuleFuncsJob("master")
92 +
93 + r.addJob("postgres", "master", job1)
94 + _, gen1 := r.getJobWithGeneration("postgres", "master")
95 + assert.Equal(t, uint64(1), gen1)
96 +
97 + r.addJob("postgres", "master", job2)
98 + got, gen2 := r.getJobWithGeneration("postgres", "master")
99 + assert.Equal(t, uint64(2), gen2)
100 + assert.Equal(t, job2, got)
101 + },
102 + },
103 + "generation verification fails on wrong generation and missing job": {
104 + run: func(t *testing.T, r *moduleFuncRegistry) {
105 + r.registerModule("postgres", collectorapi.Creator{})
106 +
107 + job := newTestModuleFuncsJob("master")
108 + r.addJob("postgres", "master", job)
109 + _, gen := r.getJobWithGeneration("postgres", "master")
110 +
111 + assert.False(t, r.verifyJobGeneration("postgres", "master", gen+1))
112 + r.removeJob("postgres", "master")
113 + assert.False(t, r.verifyJobGeneration("postgres", "master", gen))
114 + },
115 + },
116 + "get methods": {
117 + run: func(t *testing.T, r *moduleFuncRegistry) {
118 + expectedMethods := []funcapi.MethodConfig{
119 + {ID: "top-queries", Name: "Top Queries"},
120 + }
121
168 - assert.False(t, r.isModuleRegistered("nonexistent"))
169 - assert.Nil(t, r.getJobNames("nonexistent"))
170 - assert.Nil(t, r.getMethods("nonexistent"))
122 + r.registerModule("postgres", collectorapi.Creator{
123 + Methods: func() []funcapi.MethodConfig {
124 + return expectedMethods
125 + },
126 + })
127
172 - _, ok := r.getJob("nonexistent", "job1")
173 - assert.False(t, ok)
174 -}
128 + assert.Equal(t, expectedMethods, r.getMethods("postgres"))
129 + assert.Nil(t, r.getMethods("nonexistent"))
130 + },
131 + },
132 + "get job names sorted": {
133 + run: func(t *testing.T, r *moduleFuncRegistry) {
134 + r.registerModule("postgres", collectorapi.Creator{})
135
176 -func TestModuleFuncRegistry_GetCreator(t *testing.T) {
177 - r := newModuleFuncRegistry()
136 + r.addJob("postgres", "zebra-db", newTestModuleFuncsJob("zebra"))
137 + r.addJob("postgres", "alpha-db", newTestModuleFuncsJob("alpha"))
138 + r.addJob("postgres", "middle-db", newTestModuleFuncsJob("middle"))
139
179 - creator := collectorapi.Creator{
180 - JobConfigSchema: "test-schema",
140 + assert.Equal(t, []string{"alpha-db", "middle-db", "zebra-db"}, r.getJobNames("postgres"))
141 + },
142 + },
143 + "operations on unregistered module are no-op": {
144 + run: func(t *testing.T, r *moduleFuncRegistry) {
145 + r.addJob("nonexistent", "job1", newTestModuleFuncsJob("job1"))
146 + r.removeJob("nonexistent", "job1")
147 +
148 + assert.False(t, r.isModuleRegistered("nonexistent"))
149 + assert.Nil(t, r.getJobNames("nonexistent"))
150 + assert.Nil(t, r.getMethods("nonexistent"))
151 +
152 + _, ok := r.getJob("nonexistent", "job1")
153 + assert.False(t, ok)
154 + },
155 + },
156 + "get creator": {
157 + run: func(t *testing.T, r *moduleFuncRegistry) {
158 + creator := collectorapi.Creator{
159 + JobConfigSchema: "test-schema",
160 + }
161 + r.registerModule("postgres", creator)
162 +
163 + got, ok := r.getCreator("postgres")
164 + require.True(t, ok)
165 + assert.Equal(t, "test-schema", got.JobConfigSchema)
166 +
167 + _, ok = r.getCreator("nonexistent")
168 + assert.False(t, ok)
169 + },
170 + },
171 }
182 - r.registerModule("postgres", creator)
172
184 - got, ok := r.getCreator("postgres")
185 - require.True(t, ok)
186 - assert.Equal(t, "test-schema", got.JobConfigSchema)
187 -
188 - // Non-existent module
189 - _, ok = r.getCreator("nonexistent")
190 - assert.False(t, ok)
173 + for name, tc := range tests {
174 + t.Run(name, func(t *testing.T) {
175 + tc.run(t, newModuleFuncRegistry())
176 + })
177 + }
178 }
179
180 // newTestModuleFuncsJob creates a minimal job for testing modulefuncs
src/go/plugin/agent/jobmgr/noop.go
-5
@@ -4,15 +4,10 @@ package jobmgr
4
5 import (
6 "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
7 - "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
7 )
8
9 type noop struct{}
10
12 -func (n noop) Lock(string) (bool, error) { return true, nil }
13 -func (n noop) Unlock(string) {}
14 -func (n noop) UnlockAll() {}
15 -func (n noop) Lookup(string) (*vnodes.VirtualNode, bool) { return nil, false }
11 func (n noop) Register(name string, fn func(functions.Function)) {}
12 func (n noop) Unregister(name string) {}
13 func (n noop) RegisterPrefix(name, prefix string, reg func(functions.Function)) {}
src/go/plugin/agent/jobmgr/sim_test.go
+2 -2
@@ -42,9 +42,9 @@ func (s *runSim) run(t *testing.T) {
42 require.NotNil(t, s.do, "s.do is nil")
43
44 var buf bytes.Buffer
45 - mgr := New()
45 + mgr := New(Config{})
46 mgr.SetDyncfgResponder(dyncfg.NewResponder(netdataapi.New(safewriter.New(&buf))))
47 - mgr.Modules = prepareMockRegistry()
47 + mgr.modules = prepareMockRegistry()
48
49 done := make(chan struct{})
50 grpCh := make(chan []*confgroup.Group)
src/go/plugin/agent/runtimemgr/components.go
+3 -7
@@ -9,6 +9,7 @@ import (
9 "sync"
10
11 "github.com/netdata/netdata/go/plugins/pkg/metrix"
12 + "github.com/netdata/netdata/go/plugins/plugin/agent/internal/naming"
13 "github.com/netdata/netdata/go/plugins/plugin/framework/chartemit"
14 "github.com/netdata/netdata/go/plugins/plugin/framework/runtimecomp"
15 )
@@ -178,13 +179,8 @@ func firstNotEmpty(items ...string) string {
179 return ""
180 }
181
181 -func sanitizeName(name string) string {
182 - replacer := strings.NewReplacer("/", "_", "\\", "_", " ", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_")
183 - return replacer.Replace(name)
184 -}
185 -
182 func defaultInternalTypeID(pluginName, componentName string) string {
187 - plugin := sanitizeName(firstNotEmpty(pluginName, "go.d"))
188 - component := sanitizeName(componentName)
183 + plugin := naming.Sanitize(firstNotEmpty(pluginName, "go.d"))
184 + component := naming.Sanitize(componentName)
185 return fmt.Sprintf("netdata.%s.internal.%s", plugin, component)
186 }
src/go/plugin/framework/dyncfg/handler.go
+99
@@ -4,6 +4,7 @@ package dyncfg
4
5 import (
6 "errors"
7 + "sync"
8
9 "github.com/netdata/netdata/go/plugins/logger"
10 "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
@@ -54,6 +55,7 @@ type HandlerOpts[C Config] struct {
55 Seen *SeenCache[C]
56 Exposed *ExposedCache[C]
57 Callbacks Callbacks[C]
58 + WaitKey func(cfg C) string // optional key used to gate config processing until enable/disable
59
60 Path string // dyncfg path (e.g. "/collectors/go.d/Jobs")
61 EnableFailCode int // response code for enable failure (jobmgr: 200, SD: 422)
@@ -74,6 +76,9 @@ type Handler[C Config] struct {
76 enableFailCode int
77 removeStockOnEnableFail bool
78 jobCommands []Command
79 + waitKeyFn func(cfg C) string
80 + waitKey string
81 + waitMu sync.RWMutex
82 }
83
84 func NewHandler[C Config](opts HandlerOpts[C]) *Handler[C] {
@@ -87,6 +92,7 @@ func NewHandler[C Config](opts HandlerOpts[C]) *Handler[C] {
92 enableFailCode: opts.EnableFailCode,
93 removeStockOnEnableFail: opts.RemoveStockOnEnableFail,
94 jobCommands: opts.JobCommands,
95 + waitKeyFn: opts.WaitKey,
96 }
97 }
98
@@ -96,6 +102,99 @@ func (h *Handler[C]) Exposed() *ExposedCache[C] { return h.exposed }
102 // SetAPI replaces the responder (e.g. to silence output in CLI mode).
103 func (h *Handler[C]) SetAPI(api *Responder) { h.api = api }
104
105 +// RememberDiscoveredConfig ensures a discovered config is present in Seen cache.
106 +func (h *Handler[C]) RememberDiscoveredConfig(cfg C) {
107 + if _, ok := h.seen.Lookup(cfg); ok {
108 + return
109 + }
110 + h.seen.Add(cfg)
111 +}
112 +
113 +// AddDiscoveredConfig upserts a discovered config into Seen and Exposed caches.
114 +func (h *Handler[C]) AddDiscoveredConfig(cfg C, status Status) *Entry[C] {
115 + h.RememberDiscoveredConfig(cfg)
116 + entry := &Entry[C]{Cfg: cfg, Status: status}
117 + h.exposed.Add(entry)
118 + return entry
119 +}
120 +
121 +// RemoveDiscoveredConfig removes a discovered config from Seen and Exposed caches.
122 +// Returns the removed Exposed entry when the removed seen config was also exposed.
123 +func (h *Handler[C]) RemoveDiscoveredConfig(cfg C) (*Entry[C], bool) {
124 + if _, ok := h.seen.Lookup(cfg); !ok {
125 + return nil, false
126 + }
127 + h.seen.Remove(cfg)
128 +
129 + entry, ok := h.exposed.LookupByKey(cfg.ExposedKey())
130 + if !ok || entry.Cfg.UID() != cfg.UID() {
131 + return nil, false
132 + }
133 +
134 + h.exposed.Remove(cfg)
135 + return entry, true
136 +}
137 +
138 +// WaitForDecision blocks non-dyncfg config processing until a matching
139 +// enable/disable command is observed for the provided config.
140 +func (h *Handler[C]) WaitForDecision(cfg C) {
141 + if h.waitKeyFn == nil {
142 + return
143 + }
144 + key := h.waitKeyFn(cfg)
145 + if key == "" {
146 + return
147 + }
148 + h.waitMu.Lock()
149 + h.waitKey = key
150 + h.waitMu.Unlock()
151 +}
152 +
153 +// WaitingForDecision reports whether config processing should currently wait
154 +// for a matching enable/disable command.
155 +func (h *Handler[C]) WaitingForDecision() bool {
156 + h.waitMu.RLock()
157 + defer h.waitMu.RUnlock()
158 + return h.waitKey != ""
159 +}
160 +
161 +// SyncDecision updates wait-state based on the incoming command.
162 +// Only a matching enable/disable command clears the current wait key.
163 +func (h *Handler[C]) SyncDecision(fn Function) {
164 + if h.waitKeyFn == nil {
165 + return
166 + }
167 + cmd := fn.Command()
168 + if cmd != CommandEnable && cmd != CommandDisable {
169 + return
170 + }
171 +
172 + h.waitMu.RLock()
173 + waitKey := h.waitKey
174 + h.waitMu.RUnlock()
175 + if waitKey == "" {
176 + return
177 + }
178 +
179 + key, _, ok := h.cb.ExtractKey(fn)
180 + if !ok {
181 + return
182 + }
183 + entry, ok := h.exposed.LookupByKey(key)
184 + if !ok {
185 + return
186 + }
187 + if h.waitKeyFn(entry.Cfg) != waitKey {
188 + return
189 + }
190 +
191 + h.waitMu.Lock()
192 + if h.waitKey == waitKey {
193 + h.waitKey = ""
194 + }
195 + h.waitMu.Unlock()
196 +}
197 +
198 // NotifyJobCreate registers/updates a config in the dyncfg API (upsert).
199 func (h *Handler[C]) NotifyJobCreate(cfg C, status Status) {
200 isDyncfg := cfg.SourceType() == "dyncfg"
src/go/plugin/framework/dyncfg/handler_test.go
+114
@@ -117,6 +117,9 @@ func newTestHandler(cb *mockCallbacks) *Handler[testConfig] {
117 Seen: NewSeenCache[testConfig](),
118 Exposed: NewExposedCache[testConfig](),
119 Callbacks: cb,
120 + WaitKey: func(cfg testConfig) string {
121 + return cfg.Source()
122 + },
123
124 Path: "/test/path",
125 EnableFailCode: 200,
@@ -146,6 +149,117 @@ func newTestFn(id, cmd, name string, payload []byte) Function {
149 })
150 }
151
152 +func TestHandler_WaitForDecision_MatchingEnableClearsWait(t *testing.T) {
153 + cb := &mockCallbacks{}
154 + h := newTestHandler(cb)
155 +
156 + cfg := testConfig{
157 + uid: "uid-job1",
158 + key: "job1",
159 + sourceType: "stock",
160 + source: "mod/job1",
161 + }
162 + h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
163 +
164 + h.WaitForDecision(cfg)
165 + assert.True(t, h.WaitingForDecision())
166 +
167 + h.SyncDecision(newTestFn("test:job1", "enable", "", nil))
168 + assert.False(t, h.WaitingForDecision())
169 +}
170 +
171 +func TestHandler_WaitForDecision_MismatchedCommandKeepsWait(t *testing.T) {
172 + cb := &mockCallbacks{}
173 + h := newTestHandler(cb)
174 +
175 + waitCfg := testConfig{
176 + uid: "uid-job1",
177 + key: "job1",
178 + sourceType: "stock",
179 + source: "mod/job1",
180 + }
181 + otherCfg := testConfig{
182 + uid: "uid-job2",
183 + key: "job2",
184 + sourceType: "stock",
185 + source: "mod/job2",
186 + }
187 + h.exposed.Add(&Entry[testConfig]{Cfg: waitCfg, Status: StatusAccepted})
188 + h.exposed.Add(&Entry[testConfig]{Cfg: otherCfg, Status: StatusAccepted})
189 +
190 + h.WaitForDecision(waitCfg)
191 + assert.True(t, h.WaitingForDecision())
192 +
193 + // Non enable/disable commands must not change wait state.
194 + h.SyncDecision(newTestFn("test:job1", "schema", "", nil))
195 + assert.True(t, h.WaitingForDecision())
196 +
197 + // Enable/disable for a different key must not clear wait state.
198 + h.SyncDecision(newTestFn("test:job2", "disable", "", nil))
199 + assert.True(t, h.WaitingForDecision())
200 +
201 + // Matching command clears wait state.
202 + h.SyncDecision(newTestFn("test:job1", "disable", "", nil))
203 + assert.False(t, h.WaitingForDecision())
204 +}
205 +
206 +func TestHandler_AddDiscoveredConfig_TracksSeenAndExposed(t *testing.T) {
207 + cb := &mockCallbacks{}
208 + h := newTestHandler(cb)
209 +
210 + cfg := testConfig{
211 + uid: "uid-job1",
212 + key: "job1",
213 + sourceType: "stock",
214 + source: "file=/tmp/job1.conf",
215 + }
216 +
217 + h.RememberDiscoveredConfig(cfg)
218 + _, ok := h.seen.Lookup(cfg)
219 + require.True(t, ok, "config should be remembered in seen cache")
220 +
221 + entry := h.AddDiscoveredConfig(cfg, StatusAccepted)
222 + require.NotNil(t, entry)
223 + assert.Equal(t, StatusAccepted, entry.Status)
224 + assert.Equal(t, cfg.UID(), entry.Cfg.UID())
225 +
226 + exposed, ok := h.exposed.LookupByKey(cfg.ExposedKey())
227 + require.True(t, ok, "config should be exposed")
228 + assert.Equal(t, cfg.UID(), exposed.Cfg.UID())
229 + assert.Equal(t, StatusAccepted, exposed.Status)
230 +}
231 +
232 +func TestHandler_RemoveDiscoveredConfig_MismatchedExposedUID(t *testing.T) {
233 + cb := &mockCallbacks{}
234 + h := newTestHandler(cb)
235 +
236 + cfg := testConfig{
237 + uid: "uid-stock",
238 + key: "job1",
239 + sourceType: "stock",
240 + source: "file=/tmp/job1.conf",
241 + }
242 + other := testConfig{
243 + uid: "uid-dyncfg",
244 + key: "job1",
245 + sourceType: "dyncfg",
246 + source: "dyncfg=user",
247 + }
248 +
249 + h.seen.Add(cfg)
250 + h.exposed.Add(&Entry[testConfig]{Cfg: other, Status: StatusRunning})
251 +
252 + entry, ok := h.RemoveDiscoveredConfig(cfg)
253 + require.False(t, ok, "mismatched exposed uid should not return an exposed entry")
254 + require.Nil(t, entry)
255 +
256 + _, stillSeen := h.seen.Lookup(cfg)
257 + assert.False(t, stillSeen, "seen config should be removed")
258 + exposed, stillExposed := h.exposed.LookupByKey(cfg.ExposedKey())
259 + require.True(t, stillExposed, "exposed entry with different uid should be preserved")
260 + assert.Equal(t, other.UID(), exposed.Cfg.UID())
261 +}
262 +
263 // --- ExtractKey Failure Tests ---
264
265 func TestCmdAdd_ExtractKeyFailure(t *testing.T) {
src/go/plugin/framework/dyncfg/helpers.go new
+23
@@ -0,0 +1,23 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package dyncfg
4 +
5 +import "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
6 +
7 +// WrapHandler adapts a dyncfg function handler to functions.Registry handler type.
8 +func WrapHandler(handler func(Function)) func(functions.Function) {
9 + return func(fn functions.Function) {
10 + handler(NewFunction(fn))
11 + }
12 +}
13 +
14 +// BindResponder swaps a component responder and keeps handler API in sync.
15 +func BindResponder[C Config](dst **Responder, handler *Handler[C], responder *Responder) {
16 + if responder == nil {
17 + return
18 + }
19 + *dst = responder
20 + if handler != nil {
21 + handler.SetAPI(responder)
22 + }
23 +}
src/go/plugin/framework/jobruntime/dump_analyzer.go new
+14
@@ -0,0 +1,14 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package jobruntime
4 +
5 +import "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
6 +
7 +// DumpAnalyzer captures dump-mode hooks used by job manager and runtime job.
8 +// Implementations can persist per-job artifacts and summarize metric structures.
9 +type DumpAnalyzer interface {
10 + RegisterJob(jobName, moduleName, dir string)
11 + RecordJobStructure(jobName, moduleName string, charts *collectorapi.Charts)
12 + UpdateJobStructure(jobName string, charts *collectorapi.Charts)
13 + RecordCollection(jobName string, mx map[string]int64)
14 +}
src/go/plugin/framework/jobruntime/job_v1.go
+5 -17
@@ -67,7 +67,7 @@ type JobConfig struct {
67 IsStock bool
68 Vnode vnodes.VirtualNode
69 DumpMode bool
70 - DumpAnalyzer interface{}
70 + DumpAnalyzer DumpAnalyzer
71 FunctionOnly bool
72 }
73
@@ -163,7 +163,7 @@ type Job struct {
163
164 // Dump mode support
165 dumpMode bool
166 - dumpAnalyzer interface{} // Will be *agent.DumpAnalyzer but avoid circular dependency
166 + dumpAnalyzer DumpAnalyzer
167 skipTracker tickstate.SkipTracker
168 }
169
@@ -267,11 +267,7 @@ func (j *Job) AutoDetection() (err error) {
267
268 // Record job structure for dump mode after successful detection
269 if j.dumpMode && j.dumpAnalyzer != nil && j.charts != nil {
270 - if analyzer, ok := j.dumpAnalyzer.(interface {
271 - RecordJobStructure(string, string, *collectorapi.Charts)
272 - }); ok {
273 - analyzer.RecordJobStructure(j.name, j.moduleName, j.charts)
274 - }
270 + j.dumpAnalyzer.RecordJobStructure(j.name, j.moduleName, j.charts)
271 }
272
273 return nil
@@ -482,11 +478,7 @@ func (j *Job) collect() collectedMetrics {
478 // Record collected metrics for dump mode
479 // TODO: The dump analyzer only records intMetrics but ignores floatMetrics
480 if j.dumpMode && j.dumpAnalyzer != nil && mx.intMetrics != nil {
485 - if analyzer, ok := j.dumpAnalyzer.(interface {
486 - RecordCollection(string, map[string]int64)
487 - }); ok {
488 - analyzer.RecordCollection(j.name, mx.intMetrics)
489 - }
481 + j.dumpAnalyzer.RecordCollection(j.name, mx.intMetrics)
482 }
483
484 return mx
@@ -565,11 +557,7 @@ func (j *Job) processMetrics(mx collectedMetrics, startTime time.Time, sinceLast
557
558 // Update dump analyzer with current chart structure for dynamic collectors
559 if j.dumpMode && j.dumpAnalyzer != nil {
568 - if analyzer, ok := j.dumpAnalyzer.(interface {
569 - UpdateJobStructure(string, *collectorapi.Charts)
570 - }); ok {
571 - analyzer.UpdateJobStructure(j.name, j.charts)
572 - }
560 + j.dumpAnalyzer.UpdateJobStructure(j.name, j.charts)
561 }
562
563 intMx := collectedMetrics{intMetrics: map[string]int64{"success": oldmetrix.Bool(updated > 0), "failed": oldmetrix.Bool(updated == 0)}}