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)}}