summaryrefslogtreecommitdiffstats
path: root/src/go/collectors/go.d.plugin/agent/module
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/go/collectors/go.d.plugin/agent/module/charts.go462
-rw-r--r--src/go/collectors/go.d.plugin/agent/module/charts_test.go380
-rw-r--r--src/go/collectors/go.d.plugin/agent/module/job.go645
-rw-r--r--src/go/collectors/go.d.plugin/agent/module/job_test.go291
-rw-r--r--src/go/collectors/go.d.plugin/agent/module/mock.go94
-rw-r--r--src/go/collectors/go.d.plugin/agent/module/mock_test.go54
-rw-r--r--src/go/collectors/go.d.plugin/agent/module/module.go77
-rw-r--r--src/go/collectors/go.d.plugin/agent/module/registry.go52
-rw-r--r--src/go/collectors/go.d.plugin/agent/module/registry_test.go34
9 files changed, 2089 insertions, 0 deletions
diff --git a/src/go/collectors/go.d.plugin/agent/module/charts.go b/src/go/collectors/go.d.plugin/agent/module/charts.go
new file mode 100644
index 000000000..2b9c35c3b
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/agent/module/charts.go
@@ -0,0 +1,462 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package module
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "unicode"
+)
+
+type (
+ ChartType string
+ DimAlgo string
+)
+
+const (
+ // Line chart type.
+ Line ChartType = "line"
+ // Area chart type.
+ Area ChartType = "area"
+ // Stacked chart type.
+ Stacked ChartType = "stacked"
+
+ // Absolute dimension algorithm.
+ // The value is to drawn as-is (interpolated to second boundary).
+ Absolute DimAlgo = "absolute"
+ // Incremental dimension algorithm.
+ // The value increases over time, the difference from the last value is presented in the chart,
+ // the server interpolates the value and calculates a per second figure.
+ Incremental DimAlgo = "incremental"
+ // PercentOfAbsolute dimension algorithm.
+ // The percent of this value compared to the total of all dimensions.
+ PercentOfAbsolute DimAlgo = "percentage-of-absolute-row"
+ // PercentOfIncremental dimension algorithm.
+ // The percent of this value compared to the incremental total of all dimensions
+ PercentOfIncremental DimAlgo = "percentage-of-incremental-row"
+)
+
+const (
+ // Not documented.
+ // https://github.com/netdata/netdata/blob/cc2586de697702f86a3c34e60e23652dd4ddcb42/database/rrd.h#L204
+
+ LabelSourceAuto = 1 << 0
+ LabelSourceConf = 1 << 1
+ LabelSourceK8s = 1 << 2
+)
+
+func (d DimAlgo) String() string {
+ switch d {
+ case Absolute, Incremental, PercentOfAbsolute, PercentOfIncremental:
+ return string(d)
+ }
+ return string(Absolute)
+}
+
+func (c ChartType) String() string {
+ switch c {
+ case Line, Area, Stacked:
+ return string(c)
+ }
+ return string(Line)
+}
+
+type (
+ // Charts is a collection of Charts.
+ Charts []*Chart
+
+ // Opts represents chart options.
+ Opts struct {
+ Obsolete bool
+ Detail bool
+ StoreFirst bool
+ Hidden bool
+ }
+
+ // Chart represents a chart.
+ // For the full description please visit https://docs.netdata.cloud/collectors/plugins.d/#chart
+ Chart struct {
+ // typeID is the unique identification of the chart, if not specified,
+ // the orchestrator will use job full name + chart ID as typeID (default behaviour).
+ typ string
+ id string
+
+ OverModule string
+ IDSep bool
+ ID string
+ OverID string
+ Title string
+ Units string
+ Fam string
+ Ctx string
+ Type ChartType
+ Priority int
+ Opts
+
+ Labels []Label
+ Dims Dims
+ Vars Vars
+
+ Retries int
+
+ remove bool
+ // created flag is used to indicate whether the chart needs to be created by the orchestrator.
+ created bool
+ // updated flag is used to indicate whether the chart was updated on last data collection interval.
+ updated bool
+
+ // ignore flag is used to indicate that the chart shouldn't be sent to the netdata plugins.d
+ ignore bool
+ }
+
+ Label struct {
+ Key string
+ Value string
+ Source int
+ }
+
+ // DimOpts represents dimension options.
+ DimOpts struct {
+ Obsolete bool
+ Hidden bool
+ NoReset bool
+ NoOverflow bool
+ }
+
+ // Dim represents a chart dimension.
+ // For detailed description please visit https://docs.netdata.cloud/collectors/plugins.d/#dimension.
+ Dim struct {
+ ID string
+ Name string
+ Algo DimAlgo
+ Mul int
+ Div int
+ DimOpts
+
+ remove bool
+ }
+
+ // Var represents a chart variable.
+ // For detailed description please visit https://docs.netdata.cloud/collectors/plugins.d/#variable
+ Var struct {
+ ID string
+ Name string
+ Value int64
+ }
+
+ // Dims is a collection of dims.
+ Dims []*Dim
+ // Vars is a collection of vars.
+ Vars []*Var
+)
+
+func (o Opts) String() string {
+ var b strings.Builder
+ if o.Detail {
+ b.WriteString(" detail")
+ }
+ if o.Hidden {
+ b.WriteString(" hidden")
+ }
+ if o.Obsolete {
+ b.WriteString(" obsolete")
+ }
+ if o.StoreFirst {
+ b.WriteString(" store_first")
+ }
+
+ if len(b.String()) == 0 {
+ return ""
+ }
+ return b.String()[1:]
+}
+
+func (o DimOpts) String() string {
+ var b strings.Builder
+ if o.Hidden {
+ b.WriteString(" hidden")
+ }
+ if o.NoOverflow {
+ b.WriteString(" nooverflow")
+ }
+ if o.NoReset {
+ b.WriteString(" noreset")
+ }
+ if o.Obsolete {
+ b.WriteString(" obsolete")
+ }
+
+ if len(b.String()) == 0 {
+ return ""
+ }
+ return b.String()[1:]
+}
+
+// Add adds (appends) a variable number of Charts.
+func (c *Charts) Add(charts ...*Chart) error {
+ for _, chart := range charts {
+ err := checkChart(chart)
+ if err != nil {
+ return fmt.Errorf("error on adding chart '%s' : %s", chart.ID, err)
+ }
+ if chart := c.Get(chart.ID); chart != nil && !chart.remove {
+ return fmt.Errorf("error on adding chart : '%s' is already in charts", chart.ID)
+ }
+ *c = append(*c, chart)
+ }
+
+ return nil
+}
+
+// Get returns the chart by ID.
+func (c Charts) Get(chartID string) *Chart {
+ idx := c.index(chartID)
+ if idx == -1 {
+ return nil
+ }
+ return c[idx]
+}
+
+// Has returns true if ChartsFunc contain the chart with the given ID, false otherwise.
+func (c Charts) Has(chartID string) bool {
+ return c.index(chartID) != -1
+}
+
+// Remove removes the chart from Charts by ID.
+// Avoid to use it in runtime.
+func (c *Charts) Remove(chartID string) error {
+ idx := c.index(chartID)
+ if idx == -1 {
+ return fmt.Errorf("error on removing chart : '%s' is not in charts", chartID)
+ }
+ copy((*c)[idx:], (*c)[idx+1:])
+ (*c)[len(*c)-1] = nil
+ *c = (*c)[:len(*c)-1]
+ return nil
+}
+
+// Copy returns a deep copy of ChartsFunc.
+func (c Charts) Copy() *Charts {
+ charts := Charts{}
+ for idx := range c {
+ charts = append(charts, c[idx].Copy())
+ }
+ return &charts
+}
+
+func (c Charts) index(chartID string) int {
+ for idx := range c {
+ if c[idx].ID == chartID {
+ return idx
+ }
+ }
+ return -1
+}
+
+// MarkNotCreated changes 'created' chart flag to false.
+// Use it to add dimension in runtime.
+func (c *Chart) MarkNotCreated() {
+ c.created = false
+}
+
+// MarkRemove sets 'remove' flag and Obsolete option to true.
+// Use it to remove chart in runtime.
+func (c *Chart) MarkRemove() {
+ c.Obsolete = true
+ c.remove = true
+}
+
+// MarkDimRemove sets 'remove' flag, Obsolete and optionally Hidden options to true.
+// Use it to remove dimension in runtime.
+func (c *Chart) MarkDimRemove(dimID string, hide bool) error {
+ if !c.HasDim(dimID) {
+ return fmt.Errorf("chart '%s' has no '%s' dimension", c.ID, dimID)
+ }
+ dim := c.GetDim(dimID)
+ dim.Obsolete = true
+ if hide {
+ dim.Hidden = true
+ }
+ dim.remove = true
+ return nil
+}
+
+// AddDim adds new dimension to the chart dimensions.
+func (c *Chart) AddDim(newDim *Dim) error {
+ err := checkDim(newDim)
+ if err != nil {
+ return fmt.Errorf("error on adding dim to chart '%s' : %s", c.ID, err)
+ }
+ if c.HasDim(newDim.ID) {
+ return fmt.Errorf("error on adding dim : '%s' is already in chart '%s' dims", newDim.ID, c.ID)
+ }
+ c.Dims = append(c.Dims, newDim)
+
+ return nil
+}
+
+// AddVar adds new variable to the chart variables.
+func (c *Chart) AddVar(newVar *Var) error {
+ err := checkVar(newVar)
+ if err != nil {
+ return fmt.Errorf("error on adding var to chart '%s' : %s", c.ID, err)
+ }
+ if c.indexVar(newVar.ID) != -1 {
+ return fmt.Errorf("error on adding var : '%s' is already in chart '%s' vars", newVar.ID, c.ID)
+ }
+ c.Vars = append(c.Vars, newVar)
+
+ return nil
+}
+
+// GetDim returns dimension by ID.
+func (c *Chart) GetDim(dimID string) *Dim {
+ idx := c.indexDim(dimID)
+ if idx == -1 {
+ return nil
+ }
+ return c.Dims[idx]
+}
+
+// RemoveDim removes dimension by ID.
+// Avoid to use it in runtime.
+func (c *Chart) RemoveDim(dimID string) error {
+ idx := c.indexDim(dimID)
+ if idx == -1 {
+ return fmt.Errorf("error on removing dim : '%s' isn't in chart '%s'", dimID, c.ID)
+ }
+ c.Dims = append(c.Dims[:idx], c.Dims[idx+1:]...)
+
+ return nil
+}
+
+// HasDim returns true if the chart contains dimension with the given ID, false otherwise.
+func (c Chart) HasDim(dimID string) bool {
+ return c.indexDim(dimID) != -1
+}
+
+// Copy returns a deep copy of the chart.
+func (c Chart) Copy() *Chart {
+ chart := c
+ chart.Dims = Dims{}
+ chart.Vars = Vars{}
+
+ for idx := range c.Dims {
+ chart.Dims = append(chart.Dims, c.Dims[idx].copy())
+ }
+ for idx := range c.Vars {
+ chart.Vars = append(chart.Vars, c.Vars[idx].copy())
+ }
+
+ return &chart
+}
+
+func (c Chart) indexDim(dimID string) int {
+ for idx := range c.Dims {
+ if c.Dims[idx].ID == dimID {
+ return idx
+ }
+ }
+ return -1
+}
+
+func (c Chart) indexVar(varID string) int {
+ for idx := range c.Vars {
+ if c.Vars[idx].ID == varID {
+ return idx
+ }
+ }
+ return -1
+}
+
+func (d Dim) copy() *Dim {
+ return &d
+}
+
+func (v Var) copy() *Var {
+ return &v
+}
+
+func checkCharts(charts ...*Chart) error {
+ for _, chart := range charts {
+ err := checkChart(chart)
+ if err != nil {
+ return fmt.Errorf("chart '%s' : %v", chart.ID, err)
+ }
+ }
+ return nil
+}
+
+func checkChart(chart *Chart) error {
+ if chart.ID == "" {
+ return errors.New("empty ID")
+ }
+
+ if chart.Title == "" {
+ return errors.New("empty Title")
+ }
+
+ if chart.Units == "" {
+ return errors.New("empty Units")
+ }
+
+ if id := checkID(chart.ID); id != -1 {
+ return fmt.Errorf("unacceptable symbol in ID : '%c'", id)
+ }
+
+ set := make(map[string]bool)
+
+ for _, d := range chart.Dims {
+ err := checkDim(d)
+ if err != nil {
+ return err
+ }
+ if set[d.ID] {
+ return fmt.Errorf("duplicate dim '%s'", d.ID)
+ }
+ set[d.ID] = true
+ }
+
+ set = make(map[string]bool)
+
+ for _, v := range chart.Vars {
+ if err := checkVar(v); err != nil {
+ return err
+ }
+ if set[v.ID] {
+ return fmt.Errorf("duplicate var '%s'", v.ID)
+ }
+ set[v.ID] = true
+ }
+ return nil
+}
+
+func checkDim(d *Dim) error {
+ if d.ID == "" {
+ return errors.New("empty dim ID")
+ }
+ if id := checkID(d.ID); id != -1 {
+ return fmt.Errorf("unacceptable symbol in dim ID '%s' : '%c'", d.ID, id)
+ }
+ return nil
+}
+
+func checkVar(v *Var) error {
+ if v.ID == "" {
+ return errors.New("empty var ID")
+ }
+ if id := checkID(v.ID); id != -1 {
+ return fmt.Errorf("unacceptable symbol in var ID '%s' : '%c'", v.ID, id)
+ }
+ return nil
+}
+
+func checkID(id string) int {
+ for _, r := range id {
+ if unicode.IsSpace(r) {
+ return int(r)
+ }
+ }
+ return -1
+}
diff --git a/src/go/collectors/go.d.plugin/agent/module/charts_test.go b/src/go/collectors/go.d.plugin/agent/module/charts_test.go
new file mode 100644
index 000000000..7c35bb33e
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/agent/module/charts_test.go
@@ -0,0 +1,380 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package module
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func createTestChart(id string) *Chart {
+ return &Chart{
+ ID: id,
+ Title: "Title",
+ Units: "units",
+ Fam: "family",
+ Ctx: "context",
+ Type: Line,
+ Dims: Dims{
+ {ID: "dim1", Algo: Absolute},
+ },
+ Vars: Vars{
+ {ID: "var1", Value: 1},
+ },
+ }
+}
+
+func TestDimAlgo_String(t *testing.T) {
+ cases := []struct {
+ expected string
+ actual fmt.Stringer
+ }{
+ {"absolute", Absolute},
+ {"incremental", Incremental},
+ {"percentage-of-absolute-row", PercentOfAbsolute},
+ {"percentage-of-incremental-row", PercentOfIncremental},
+ {"absolute", DimAlgo("wrong")},
+ }
+
+ for _, v := range cases {
+ assert.Equal(t, v.expected, v.actual.String())
+ }
+}
+
+func TestChartType_String(t *testing.T) {
+ cases := []struct {
+ expected string
+ actual fmt.Stringer
+ }{
+ {"line", Line},
+ {"area", Area},
+ {"stacked", Stacked},
+ {"line", ChartType("wrong")},
+ }
+
+ for _, v := range cases {
+ assert.Equal(t, v.expected, v.actual.String())
+ }
+}
+
+func TestOpts_String(t *testing.T) {
+ cases := []struct {
+ expected string
+ actual fmt.Stringer
+ }{
+ {"", Opts{}},
+ {
+ "detail hidden obsolete store_first",
+ Opts{Detail: true, Hidden: true, Obsolete: true, StoreFirst: true},
+ },
+ {
+ "detail hidden obsolete store_first",
+ Opts{Detail: true, Hidden: true, Obsolete: true, StoreFirst: true},
+ },
+ }
+
+ for _, v := range cases {
+ assert.Equal(t, v.expected, v.actual.String())
+ }
+}
+
+func TestDimOpts_String(t *testing.T) {
+ cases := []struct {
+ expected string
+ actual fmt.Stringer
+ }{
+ {"", DimOpts{}},
+ {
+ "hidden nooverflow noreset obsolete",
+ DimOpts{Hidden: true, NoOverflow: true, NoReset: true, Obsolete: true},
+ },
+ {
+ "hidden obsolete",
+ DimOpts{Hidden: true, NoOverflow: false, NoReset: false, Obsolete: true},
+ },
+ }
+
+ for _, v := range cases {
+ assert.Equal(t, v.expected, v.actual.String())
+ }
+}
+
+func TestCharts_Copy(t *testing.T) {
+ orig := &Charts{
+ createTestChart("1"),
+ createTestChart("2"),
+ }
+ copied := orig.Copy()
+
+ require.False(t, orig == copied, "Charts copy points to the same address")
+ require.Len(t, *orig, len(*copied))
+
+ for idx := range *orig {
+ compareCharts(t, (*orig)[idx], (*copied)[idx])
+ }
+}
+
+func TestChart_Copy(t *testing.T) {
+ orig := createTestChart("1")
+
+ compareCharts(t, orig, orig.Copy())
+}
+
+func TestCharts_Add(t *testing.T) {
+ charts := Charts{}
+ chart1 := createTestChart("1")
+ chart2 := createTestChart("2")
+ chart3 := createTestChart("")
+
+ // OK case
+ assert.NoError(t, charts.Add(
+ chart1,
+ chart2,
+ ))
+ assert.Len(t, charts, 2)
+
+ // NG case
+ assert.Error(t, charts.Add(
+ chart3,
+ chart1,
+ chart2,
+ ))
+ assert.Len(t, charts, 2)
+
+ assert.True(t, charts[0] == chart1)
+ assert.True(t, charts[1] == chart2)
+}
+
+func TestCharts_Add_SameID(t *testing.T) {
+ charts := Charts{}
+ chart1 := createTestChart("1")
+ chart2 := createTestChart("1")
+
+ assert.NoError(t, charts.Add(chart1))
+ assert.Error(t, charts.Add(chart2))
+ assert.Len(t, charts, 1)
+
+ charts = Charts{}
+ chart1 = createTestChart("1")
+ chart2 = createTestChart("1")
+
+ assert.NoError(t, charts.Add(chart1))
+ chart1.MarkRemove()
+ assert.NoError(t, charts.Add(chart2))
+ assert.Len(t, charts, 2)
+}
+
+func TestCharts_Get(t *testing.T) {
+ chart := createTestChart("1")
+ charts := Charts{
+ chart,
+ }
+
+ // OK case
+ assert.True(t, chart == charts.Get("1"))
+ // NG case
+ assert.Nil(t, charts.Get("2"))
+}
+
+func TestCharts_Has(t *testing.T) {
+ chart := createTestChart("1")
+ charts := &Charts{
+ chart,
+ }
+
+ // OK case
+ assert.True(t, charts.Has("1"))
+ // NG case
+ assert.False(t, charts.Has("2"))
+}
+
+func TestCharts_Remove(t *testing.T) {
+ chart := createTestChart("1")
+ charts := &Charts{
+ chart,
+ }
+
+ // OK case
+ assert.NoError(t, charts.Remove("1"))
+ assert.Len(t, *charts, 0)
+
+ // NG case
+ assert.Error(t, charts.Remove("2"))
+}
+
+func TestChart_AddDim(t *testing.T) {
+ chart := createTestChart("1")
+ dim := &Dim{ID: "dim2"}
+
+ // OK case
+ assert.NoError(t, chart.AddDim(dim))
+ assert.Len(t, chart.Dims, 2)
+
+ // NG case
+ assert.Error(t, chart.AddDim(dim))
+ assert.Len(t, chart.Dims, 2)
+}
+
+func TestChart_AddVar(t *testing.T) {
+ chart := createTestChart("1")
+ variable := &Var{ID: "var2"}
+
+ // OK case
+ assert.NoError(t, chart.AddVar(variable))
+ assert.Len(t, chart.Vars, 2)
+
+ // NG case
+ assert.Error(t, chart.AddVar(variable))
+ assert.Len(t, chart.Vars, 2)
+}
+
+func TestChart_GetDim(t *testing.T) {
+ chart := &Chart{
+ Dims: Dims{
+ {ID: "1"},
+ {ID: "2"},
+ },
+ }
+
+ // OK case
+ assert.True(t, chart.GetDim("1") != nil && chart.GetDim("1").ID == "1")
+
+ // NG case
+ assert.Nil(t, chart.GetDim("3"))
+}
+
+func TestChart_RemoveDim(t *testing.T) {
+ chart := createTestChart("1")
+
+ // OK case
+ assert.NoError(t, chart.RemoveDim("dim1"))
+ assert.Len(t, chart.Dims, 0)
+
+ // NG case
+ assert.Error(t, chart.RemoveDim("dim2"))
+}
+
+func TestChart_HasDim(t *testing.T) {
+ chart := createTestChart("1")
+
+ // OK case
+ assert.True(t, chart.HasDim("dim1"))
+ // NG case
+ assert.False(t, chart.HasDim("dim2"))
+}
+
+func TestChart_MarkNotCreated(t *testing.T) {
+ chart := createTestChart("1")
+
+ chart.MarkNotCreated()
+ assert.False(t, chart.created)
+}
+
+func TestChart_MarkRemove(t *testing.T) {
+ chart := createTestChart("1")
+
+ chart.MarkRemove()
+ assert.True(t, chart.remove)
+ assert.True(t, chart.Obsolete)
+}
+
+func TestChart_MarkDimRemove(t *testing.T) {
+ chart := createTestChart("1")
+
+ assert.Error(t, chart.MarkDimRemove("dim99", false))
+ assert.NoError(t, chart.MarkDimRemove("dim1", true))
+ assert.True(t, chart.GetDim("dim1").Obsolete)
+ assert.True(t, chart.GetDim("dim1").Hidden)
+ assert.True(t, chart.GetDim("dim1").remove)
+}
+
+func TestChart_check(t *testing.T) {
+ // OK case
+ chart := createTestChart("1")
+ assert.NoError(t, checkChart(chart))
+
+ // NG case
+ chart = createTestChart("1")
+ chart.ID = ""
+ assert.Error(t, checkChart(chart))
+
+ chart = createTestChart("1")
+ chart.ID = "invalid id"
+ assert.Error(t, checkChart(chart))
+
+ chart = createTestChart("1")
+ chart.Title = ""
+ assert.Error(t, checkChart(chart))
+
+ chart = createTestChart("1")
+ chart.Units = ""
+ assert.Error(t, checkChart(chart))
+
+ chart = createTestChart("1")
+ chart.Dims = Dims{
+ {ID: "1"},
+ {ID: "1"},
+ }
+ assert.Error(t, checkChart(chart))
+
+ chart = createTestChart("1")
+ chart.Vars = Vars{
+ {ID: "1"},
+ {ID: "1"},
+ }
+ assert.Error(t, checkChart(chart))
+}
+
+func TestDim_check(t *testing.T) {
+ // OK case
+ dim := &Dim{ID: "id"}
+ assert.NoError(t, checkDim(dim))
+
+ // NG case
+ dim = &Dim{ID: "id"}
+ dim.ID = ""
+ assert.Error(t, checkDim(dim))
+
+ dim = &Dim{ID: "id"}
+ dim.ID = "invalid id"
+ assert.Error(t, checkDim(dim))
+}
+
+func TestVar_check(t *testing.T) {
+ // OK case
+ v := &Var{ID: "id"}
+ assert.NoError(t, checkVar(v))
+
+ // NG case
+ v = &Var{ID: "id"}
+ v.ID = ""
+ assert.Error(t, checkVar(v))
+
+ v = &Var{ID: "id"}
+ v.ID = "invalid id"
+ assert.Error(t, checkVar(v))
+}
+
+func compareCharts(t *testing.T, orig, copied *Chart) {
+ // 1. compare chart pointers
+ // 2. compare Dims, Vars length
+ // 3. compare Dims, Vars pointers
+
+ assert.False(t, orig == copied, "Chart copy ChartsFunc points to the same address")
+
+ require.Len(t, orig.Dims, len(copied.Dims))
+ require.Len(t, orig.Vars, len(copied.Vars))
+
+ for idx := range (*orig).Dims {
+ assert.False(t, orig.Dims[idx] == copied.Dims[idx], "Chart copy dim points to the same address")
+ assert.Equal(t, orig.Dims[idx], copied.Dims[idx], "Chart copy dim isn't equal to orig")
+ }
+
+ for idx := range (*orig).Vars {
+ assert.False(t, orig.Vars[idx] == copied.Vars[idx], "Chart copy var points to the same address")
+ assert.Equal(t, orig.Vars[idx], copied.Vars[idx], "Chart copy var isn't equal to orig")
+ }
+}
diff --git a/src/go/collectors/go.d.plugin/agent/module/job.go b/src/go/collectors/go.d.plugin/agent/module/job.go
new file mode 100644
index 000000000..cb15fdc2e
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/agent/module/job.go
@@ -0,0 +1,645 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package module
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "regexp"
+ "runtime/debug"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/netdata/netdata/go/go.d.plugin/agent/netdataapi"
+ "github.com/netdata/netdata/go/go.d.plugin/agent/vnodes"
+ "github.com/netdata/netdata/go/go.d.plugin/logger"
+)
+
+var obsoleteLock = &sync.Mutex{}
+var obsoleteCharts = true
+
+func DontObsoleteCharts() {
+ obsoleteLock.Lock()
+ obsoleteCharts = false
+ obsoleteLock.Unlock()
+}
+
+func shouldObsoleteCharts() bool {
+ obsoleteLock.Lock()
+ defer obsoleteLock.Unlock()
+ return obsoleteCharts
+}
+
+var reSpace = regexp.MustCompile(`\s+`)
+
+var ndInternalMonitoringDisabled = os.Getenv("NETDATA_INTERNALS_MONITORING") == "NO"
+
+func newRuntimeChart(pluginName string) *Chart {
+ // this is needed to keep the same name as we had before https://github.com/netdata/netdata/go/go.d.plugin/issues/650
+ ctxName := pluginName
+ if ctxName == "go.d" {
+ ctxName = "go"
+ }
+ ctxName = reSpace.ReplaceAllString(ctxName, "_")
+ return &Chart{
+ typ: "netdata",
+ Title: "Execution time",
+ Units: "ms",
+ Fam: pluginName,
+ Ctx: fmt.Sprintf("netdata.%s_plugin_execution_time", ctxName),
+ Priority: 145000,
+ Dims: Dims{
+ {ID: "time"},
+ },
+ }
+}
+
+type JobConfig struct {
+ PluginName string
+ Name string
+ ModuleName string
+ FullName string
+ Module Module
+ Labels map[string]string
+ Out io.Writer
+ UpdateEvery int
+ AutoDetectEvery int
+ Priority int
+ IsStock bool
+
+ VnodeGUID string
+ VnodeHostname string
+ VnodeLabels map[string]string
+}
+
+const (
+ penaltyStep = 5
+ maxPenalty = 600
+ infTries = -1
+)
+
+func NewJob(cfg JobConfig) *Job {
+ var buf bytes.Buffer
+
+ if cfg.UpdateEvery == 0 {
+ cfg.UpdateEvery = 1
+ }
+
+ j := &Job{
+ AutoDetectEvery: cfg.AutoDetectEvery,
+ AutoDetectTries: infTries,
+
+ pluginName: cfg.PluginName,
+ name: cfg.Name,
+ moduleName: cfg.ModuleName,
+ fullName: cfg.FullName,
+ updateEvery: cfg.UpdateEvery,
+ priority: cfg.Priority,
+ isStock: cfg.IsStock,
+ module: cfg.Module,
+ labels: cfg.Labels,
+ out: cfg.Out,
+ runChart: newRuntimeChart(cfg.PluginName),
+ stop: make(chan struct{}),
+ tick: make(chan int),
+ buf: &buf,
+ api: netdataapi.New(&buf),
+
+ vnodeGUID: cfg.VnodeGUID,
+ vnodeHostname: cfg.VnodeHostname,
+ vnodeLabels: cfg.VnodeLabels,
+ }
+
+ log := logger.New().With(
+ slog.String("collector", j.ModuleName()),
+ slog.String("job", j.Name()),
+ )
+
+ j.Logger = log
+ if j.module != nil {
+ j.module.GetBase().Logger = log
+ }
+
+ return j
+}
+
+// Job represents a job. It's a module wrapper.
+type Job struct {
+ pluginName string
+ name string
+ moduleName string
+ fullName string
+
+ updateEvery int
+ AutoDetectEvery int
+ AutoDetectTries int
+ priority int
+ labels map[string]string
+
+ *logger.Logger
+
+ isStock bool
+
+ module Module
+
+ initialized bool
+ panicked bool
+
+ runChart *Chart
+ charts *Charts
+ tick chan int
+ out io.Writer
+ buf *bytes.Buffer
+ api *netdataapi.API
+
+ retries int
+ prevRun time.Time
+
+ stop chan struct{}
+
+ vnodeCreated bool
+ vnodeGUID string
+ vnodeHostname string
+ vnodeLabels map[string]string
+}
+
+// NetdataChartIDMaxLength is the chart ID max length. See RRD_ID_LENGTH_MAX in the netdata source code.
+const NetdataChartIDMaxLength = 1200
+
+// FullName returns job full name.
+func (j *Job) FullName() string {
+ return j.fullName
+}
+
+// ModuleName returns job module name.
+func (j *Job) ModuleName() string {
+ return j.moduleName
+}
+
+// Name returns job name.
+func (j *Job) Name() string {
+ return j.name
+}
+
+// Panicked returns 'panicked' flag value.
+func (j *Job) Panicked() bool {
+ return j.panicked
+}
+
+// AutoDetectionEvery returns value of AutoDetectEvery.
+func (j *Job) AutoDetectionEvery() int {
+ return j.AutoDetectEvery
+}
+
+// RetryAutoDetection returns whether it is needed to retry autodetection.
+func (j *Job) RetryAutoDetection() bool {
+ return j.AutoDetectEvery > 0 && (j.AutoDetectTries == infTries || j.AutoDetectTries > 0)
+}
+
+func (j *Job) Configuration() any {
+ return j.module.Configuration()
+}
+
+// AutoDetection invokes init, check and postCheck. It handles panic.
+func (j *Job) AutoDetection() (err error) {
+ defer func() {
+ if r := recover(); r != nil {
+ err = fmt.Errorf("panic %v", err)
+ j.panicked = true
+ j.disableAutoDetection()
+
+ j.Errorf("PANIC %v", r)
+ if logger.Level.Enabled(slog.LevelDebug) {
+ j.Errorf("STACK: %s", debug.Stack())
+ }
+ }
+ if err != nil {
+ j.module.Cleanup()
+ }
+ }()
+
+ if j.isStock {
+ j.Mute()
+ }
+
+ if err = j.init(); err != nil {
+ j.Error("init failed")
+ j.Unmute()
+ j.disableAutoDetection()
+ return err
+ }
+
+ if err = j.check(); err != nil {
+ j.Error("check failed")
+ j.Unmute()
+ return err
+ }
+
+ j.Unmute()
+ j.Info("check success")
+
+ if err = j.postCheck(); err != nil {
+ j.Error("postCheck failed")
+ j.disableAutoDetection()
+ return err
+ }
+
+ return nil
+}
+
+// Tick Tick.
+func (j *Job) Tick(clock int) {
+ select {
+ case j.tick <- clock:
+ default:
+ j.Debug("skip the tick due to previous run hasn't been finished")
+ }
+}
+
+// Start starts job main loop.
+func (j *Job) Start() {
+ j.Infof("started, data collection interval %ds", j.updateEvery)
+ defer func() { j.Info("stopped") }()
+
+LOOP:
+ for {
+ select {
+ case <-j.stop:
+ break LOOP
+ case t := <-j.tick:
+ if t%(j.updateEvery+j.penalty()) == 0 {
+ j.runOnce()
+ }
+ }
+ }
+ j.module.Cleanup()
+ j.Cleanup()
+ j.stop <- struct{}{}
+}
+
+// Stop stops job main loop. It blocks until the job is stopped.
+func (j *Job) Stop() {
+ // TODO: should have blocking and non blocking stop
+ j.stop <- struct{}{}
+ <-j.stop
+}
+
+func (j *Job) disableAutoDetection() {
+ j.AutoDetectEvery = 0
+}
+
+func (j *Job) Cleanup() {
+ j.buf.Reset()
+ if !shouldObsoleteCharts() {
+ return
+ }
+
+ if !vnodes.Disabled {
+ if !j.vnodeCreated && j.vnodeGUID != "" {
+ _ = j.api.HOSTINFO(j.vnodeGUID, j.vnodeHostname, j.vnodeLabels)
+ j.vnodeCreated = true
+ }
+ _ = j.api.HOST(j.vnodeGUID)
+ }
+
+ if j.runChart.created {
+ j.runChart.MarkRemove()
+ j.createChart(j.runChart)
+ }
+ if j.charts != nil {
+ for _, chart := range *j.charts {
+ if chart.created {
+ chart.MarkRemove()
+ j.createChart(chart)
+ }
+ }
+ }
+
+ if j.buf.Len() > 0 {
+ _, _ = io.Copy(j.out, j.buf)
+ }
+}
+
+func (j *Job) init() error {
+ if j.initialized {
+ return nil
+ }
+
+ if err := j.module.Init(); err != nil {
+ return err
+ }
+
+ j.initialized = true
+
+ return nil
+}
+
+func (j *Job) check() error {
+ if err := j.module.Check(); err != nil {
+ if j.AutoDetectTries != infTries {
+ j.AutoDetectTries--
+ }
+ return err
+ }
+ return nil
+}
+
+func (j *Job) postCheck() error {
+ if j.charts = j.module.Charts(); j.charts == nil {
+ j.Error("nil charts")
+ return errors.New("nil charts")
+ }
+ if err := checkCharts(*j.charts...); err != nil {
+ j.Errorf("charts check: %v", err)
+ return err
+ }
+ return nil
+}
+
+func (j *Job) runOnce() {
+ curTime := time.Now()
+ sinceLastRun := calcSinceLastRun(curTime, j.prevRun)
+ j.prevRun = curTime
+
+ metrics := j.collect()
+
+ if j.panicked {
+ return
+ }
+
+ if j.processMetrics(metrics, curTime, sinceLastRun) {
+ j.retries = 0
+ } else {
+ j.retries++
+ }
+
+ _, _ = io.Copy(j.out, j.buf)
+ j.buf.Reset()
+}
+
+func (j *Job) collect() (result map[string]int64) {
+ j.panicked = false
+ defer func() {
+ if r := recover(); r != nil {
+ j.panicked = true
+ j.Errorf("PANIC: %v", r)
+ if logger.Level.Enabled(slog.LevelDebug) {
+ j.Errorf("STACK: %s", debug.Stack())
+ }
+ }
+ }()
+ return j.module.Collect()
+}
+
+func (j *Job) processMetrics(metrics map[string]int64, startTime time.Time, sinceLastRun int) bool {
+ if !vnodes.Disabled {
+ if !j.vnodeCreated && j.vnodeGUID != "" {
+ _ = j.api.HOSTINFO(j.vnodeGUID, j.vnodeHostname, j.vnodeLabels)
+ j.vnodeCreated = true
+ }
+
+ _ = j.api.HOST(j.vnodeGUID)
+ }
+
+ if !ndInternalMonitoringDisabled && !j.runChart.created {
+ j.runChart.ID = fmt.Sprintf("execution_time_of_%s", j.FullName())
+ j.createChart(j.runChart)
+ }
+
+ elapsed := int64(durationTo(time.Since(startTime), time.Millisecond))
+
+ var i, updated int
+ for _, chart := range *j.charts {
+ if !chart.created {
+ typeID := fmt.Sprintf("%s.%s", j.FullName(), chart.ID)
+ if len(typeID) >= NetdataChartIDMaxLength {
+ j.Warningf("chart 'type.id' length (%d) >= max allowed (%d), the chart is ignored (%s)",
+ len(typeID), NetdataChartIDMaxLength, typeID)
+ chart.ignore = true
+ }
+ j.createChart(chart)
+ }
+ if chart.remove {
+ continue
+ }
+ (*j.charts)[i] = chart
+ i++
+ if len(metrics) == 0 || chart.Obsolete {
+ continue
+ }
+ if j.updateChart(chart, metrics, sinceLastRun) {
+ updated++
+ }
+ }
+ *j.charts = (*j.charts)[:i]
+
+ if updated == 0 {
+ return false
+ }
+ if !ndInternalMonitoringDisabled {
+ j.updateChart(j.runChart, map[string]int64{"time": elapsed}, sinceLastRun)
+ }
+
+ return true
+}
+
+func (j *Job) createChart(chart *Chart) {
+ defer func() { chart.created = true }()
+ if chart.ignore {
+ return
+ }
+
+ if chart.Priority == 0 {
+ chart.Priority = j.priority
+ j.priority++
+ }
+ _ = j.api.CHART(
+ getChartType(chart, j),
+ getChartID(chart),
+ chart.OverID,
+ chart.Title,
+ chart.Units,
+ chart.Fam,
+ chart.Ctx,
+ chart.Type.String(),
+ chart.Priority,
+ j.updateEvery,
+ chart.Opts.String(),
+ j.pluginName,
+ j.moduleName,
+ )
+
+ if chart.Obsolete {
+ _ = j.api.EMPTYLINE()
+ return
+ }
+
+ seen := make(map[string]bool)
+ for _, l := range chart.Labels {
+ if l.Key != "" {
+ seen[l.Key] = true
+ ls := l.Source
+ // the default should be auto
+ // https://github.com/netdata/netdata/blob/cc2586de697702f86a3c34e60e23652dd4ddcb42/database/rrd.h#L205
+ if ls == 0 {
+ ls = LabelSourceAuto
+ }
+ _ = j.api.CLABEL(l.Key, l.Value, ls)
+ }
+ }
+ for k, v := range j.labels {
+ if !seen[k] {
+ _ = j.api.CLABEL(k, v, LabelSourceConf)
+ }
+ }
+ _ = j.api.CLABEL("_collect_job", j.Name(), LabelSourceAuto)
+ _ = j.api.CLABELCOMMIT()
+
+ for _, dim := range chart.Dims {
+ _ = j.api.DIMENSION(
+ firstNotEmpty(dim.Name, dim.ID),
+ dim.Name,
+ dim.Algo.String(),
+ handleZero(dim.Mul),
+ handleZero(dim.Div),
+ dim.DimOpts.String(),
+ )
+ }
+ for _, v := range chart.Vars {
+ if v.Name != "" {
+ _ = j.api.VARIABLE(v.Name, v.Value)
+ } else {
+ _ = j.api.VARIABLE(v.ID, v.Value)
+ }
+ }
+ _ = j.api.EMPTYLINE()
+}
+
+func (j *Job) updateChart(chart *Chart, collected map[string]int64, sinceLastRun int) bool {
+ if chart.ignore {
+ dims := chart.Dims[:0]
+ for _, dim := range chart.Dims {
+ if !dim.remove {
+ dims = append(dims, dim)
+ }
+ }
+ chart.Dims = dims
+ return false
+ }
+
+ if !chart.updated {
+ sinceLastRun = 0
+ }
+
+ _ = j.api.BEGIN(
+ getChartType(chart, j),
+ getChartID(chart),
+ sinceLastRun,
+ )
+ var i, updated int
+ for _, dim := range chart.Dims {
+ if dim.remove {
+ continue
+ }
+ chart.Dims[i] = dim
+ i++
+ if v, ok := collected[dim.ID]; !ok {
+ _ = j.api.SETEMPTY(firstNotEmpty(dim.Name, dim.ID))
+ } else {
+ _ = j.api.SET(firstNotEmpty(dim.Name, dim.ID), v)
+ updated++
+ }
+ }
+ chart.Dims = chart.Dims[:i]
+
+ for _, vr := range chart.Vars {
+ if v, ok := collected[vr.ID]; ok {
+ if vr.Name != "" {
+ _ = j.api.VARIABLE(vr.Name, v)
+ } else {
+ _ = j.api.VARIABLE(vr.ID, v)
+ }
+ }
+
+ }
+ _ = j.api.END()
+
+ if chart.updated = updated > 0; chart.updated {
+ chart.Retries = 0
+ } else {
+ chart.Retries++
+ }
+ return chart.updated
+}
+
+func (j *Job) penalty() int {
+ v := j.retries / penaltyStep * penaltyStep * j.updateEvery / 2
+ if v > maxPenalty {
+ return maxPenalty
+ }
+ return v
+}
+
+func getChartType(chart *Chart, j *Job) string {
+ if chart.typ != "" {
+ return chart.typ
+ }
+ if !chart.IDSep {
+ chart.typ = j.FullName()
+ } else if i := strings.IndexByte(chart.ID, '.'); i != -1 {
+ chart.typ = j.FullName() + "_" + chart.ID[:i]
+ } else {
+ chart.typ = j.FullName()
+ }
+ if chart.OverModule != "" {
+ if v := strings.TrimPrefix(chart.typ, j.ModuleName()); v != chart.typ {
+ chart.typ = chart.OverModule + v
+ }
+ }
+ return chart.typ
+}
+
+func getChartID(chart *Chart) string {
+ if chart.id != "" {
+ return chart.id
+ }
+ if !chart.IDSep {
+ return chart.ID
+ }
+ if i := strings.IndexByte(chart.ID, '.'); i != -1 {
+ chart.id = chart.ID[i+1:]
+ } else {
+ chart.id = chart.ID
+ }
+ return chart.id
+}
+
+func calcSinceLastRun(curTime, prevRun time.Time) int {
+ if prevRun.IsZero() {
+ return 0
+ }
+ return int((curTime.UnixNano() - prevRun.UnixNano()) / 1000)
+}
+
+func durationTo(duration time.Duration, to time.Duration) int {
+ return int(int64(duration) / (int64(to) / int64(time.Nanosecond)))
+}
+
+func firstNotEmpty(val1, val2 string) string {
+ if val1 != "" {
+ return val1
+ }
+ return val2
+}
+
+func handleZero(v int) int {
+ if v == 0 {
+ return 1
+ }
+ return v
+}
diff --git a/src/go/collectors/go.d.plugin/agent/module/job_test.go b/src/go/collectors/go.d.plugin/agent/module/job_test.go
new file mode 100644
index 000000000..c87f840d5
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/agent/module/job_test.go
@@ -0,0 +1,291 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package module
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+const (
+ pluginName = "plugin"
+ modName = "module"
+ jobName = "job"
+)
+
+func newTestJob() *Job {
+ return NewJob(
+ JobConfig{
+ PluginName: pluginName,
+ Name: jobName,
+ ModuleName: modName,
+ FullName: modName + "_" + jobName,
+ Module: nil,
+ Out: io.Discard,
+ UpdateEvery: 0,
+ AutoDetectEvery: 0,
+ Priority: 0,
+ },
+ )
+}
+
+func TestNewJob(t *testing.T) {
+ assert.IsType(t, (*Job)(nil), newTestJob())
+}
+
+func TestJob_FullName(t *testing.T) {
+ job := newTestJob()
+
+ assert.Equal(t, job.FullName(), fmt.Sprintf("%s_%s", modName, jobName))
+}
+
+func TestJob_ModuleName(t *testing.T) {
+ job := newTestJob()
+
+ assert.Equal(t, job.ModuleName(), modName)
+}
+
+func TestJob_Name(t *testing.T) {
+ job := newTestJob()
+
+ assert.Equal(t, job.Name(), jobName)
+}
+
+func TestJob_Panicked(t *testing.T) {
+ job := newTestJob()
+
+ assert.Equal(t, job.Panicked(), job.panicked)
+ job.panicked = true
+ assert.Equal(t, job.Panicked(), job.panicked)
+}
+
+func TestJob_AutoDetectionEvery(t *testing.T) {
+ job := newTestJob()
+
+ assert.Equal(t, job.AutoDetectionEvery(), job.AutoDetectEvery)
+}
+
+func TestJob_RetryAutoDetection(t *testing.T) {
+ job := newTestJob()
+ m := &MockModule{
+ InitFunc: func() error {
+ return nil
+ },
+ CheckFunc: func() error { return errors.New("check error") },
+ ChartsFunc: func() *Charts {
+ return &Charts{}
+ },
+ }
+ job.module = m
+ job.AutoDetectEvery = 1
+
+ assert.True(t, job.RetryAutoDetection())
+ assert.Equal(t, infTries, job.AutoDetectTries)
+ for i := 0; i < 1000; i++ {
+ _ = job.check()
+ }
+ assert.True(t, job.RetryAutoDetection())
+ assert.Equal(t, infTries, job.AutoDetectTries)
+
+ job.AutoDetectTries = 10
+ for i := 0; i < 10; i++ {
+ _ = job.check()
+ }
+ assert.False(t, job.RetryAutoDetection())
+ assert.Equal(t, 0, job.AutoDetectTries)
+}
+
+func TestJob_AutoDetection(t *testing.T) {
+ job := newTestJob()
+ var v int
+ m := &MockModule{
+ InitFunc: func() error {
+ v++
+ return nil
+ },
+ CheckFunc: func() error {
+ v++
+ return nil
+ },
+ ChartsFunc: func() *Charts {
+ v++
+ return &Charts{}
+ },
+ }
+ job.module = m
+
+ assert.NoError(t, job.AutoDetection())
+ assert.Equal(t, 3, v)
+}
+
+func TestJob_AutoDetection_FailInit(t *testing.T) {
+ job := newTestJob()
+ m := &MockModule{
+ InitFunc: func() error {
+ return errors.New("init error")
+ },
+ }
+ job.module = m
+
+ assert.Error(t, job.AutoDetection())
+ assert.True(t, m.CleanupDone)
+}
+
+func TestJob_AutoDetection_FailCheck(t *testing.T) {
+ job := newTestJob()
+ m := &MockModule{
+ InitFunc: func() error {
+ return nil
+ },
+ CheckFunc: func() error {
+ return errors.New("check error")
+ },
+ }
+ job.module = m
+
+ assert.Error(t, job.AutoDetection())
+ assert.True(t, m.CleanupDone)
+}
+
+func TestJob_AutoDetection_FailPostCheck(t *testing.T) {
+ job := newTestJob()
+ m := &MockModule{
+ InitFunc: func() error {
+ return nil
+ },
+ CheckFunc: func() error {
+ return nil
+ },
+ ChartsFunc: func() *Charts {
+ return nil
+ },
+ }
+ job.module = m
+
+ assert.Error(t, job.AutoDetection())
+ assert.True(t, m.CleanupDone)
+}
+
+func TestJob_AutoDetection_PanicInit(t *testing.T) {
+ job := newTestJob()
+ m := &MockModule{
+ InitFunc: func() error {
+ panic("panic in Init")
+ },
+ }
+ job.module = m
+
+ assert.Error(t, job.AutoDetection())
+ assert.True(t, m.CleanupDone)
+}
+
+func TestJob_AutoDetection_PanicCheck(t *testing.T) {
+ job := newTestJob()
+ m := &MockModule{
+ InitFunc: func() error {
+ return nil
+ },
+ CheckFunc: func() error {
+ panic("panic in Check")
+ },
+ }
+ job.module = m
+
+ assert.Error(t, job.AutoDetection())
+ assert.True(t, m.CleanupDone)
+}
+
+func TestJob_AutoDetection_PanicPostCheck(t *testing.T) {
+ job := newTestJob()
+ m := &MockModule{
+ InitFunc: func() error {
+ return nil
+ },
+ CheckFunc: func() error {
+ return nil
+ },
+ ChartsFunc: func() *Charts {
+ panic("panic in PostCheck")
+ },
+ }
+ job.module = m
+
+ assert.Error(t, job.AutoDetection())
+ assert.True(t, m.CleanupDone)
+}
+
+func TestJob_Start(t *testing.T) {
+ m := &MockModule{
+ ChartsFunc: func() *Charts {
+ return &Charts{
+ &Chart{
+ ID: "id",
+ Title: "title",
+ Units: "units",
+ Dims: Dims{
+ {ID: "id1"},
+ {ID: "id2"},
+ },
+ },
+ }
+ },
+ CollectFunc: func() map[string]int64 {
+ return map[string]int64{
+ "id1": 1,
+ "id2": 2,
+ }
+ },
+ }
+ job := newTestJob()
+ job.module = m
+ job.charts = job.module.Charts()
+ job.updateEvery = 1
+
+ go func() {
+ for i := 1; i < 3; i++ {
+ job.Tick(i)
+ time.Sleep(time.Second)
+ }
+ job.Stop()
+ }()
+
+ job.Start()
+
+ assert.True(t, m.CleanupDone)
+}
+
+func TestJob_MainLoop_Panic(t *testing.T) {
+ m := &MockModule{
+ CollectFunc: func() map[string]int64 {
+ panic("panic in Collect")
+ },
+ }
+ job := newTestJob()
+ job.module = m
+ job.updateEvery = 1
+
+ go func() {
+ for i := 1; i < 3; i++ {
+ time.Sleep(time.Second)
+ job.Tick(i)
+ }
+ job.Stop()
+ }()
+
+ job.Start()
+
+ assert.True(t, job.Panicked())
+ assert.True(t, m.CleanupDone)
+}
+
+func TestJob_Tick(t *testing.T) {
+ job := newTestJob()
+ for i := 0; i < 3; i++ {
+ job.Tick(i)
+ }
+}
diff --git a/src/go/collectors/go.d.plugin/agent/module/mock.go b/src/go/collectors/go.d.plugin/agent/module/mock.go
new file mode 100644
index 000000000..f83c7dbcc
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/agent/module/mock.go
@@ -0,0 +1,94 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package module
+
+import "errors"
+
+const MockConfigSchema = `
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "properties": {
+ "option_str": {
+ "type": "string",
+ "description": "Option string value"
+ },
+ "option_int": {
+ "type": "integer",
+ "description": "Option integer value"
+ }
+ },
+ "required": [
+ "option_str",
+ "option_int"
+ ]
+}
+`
+
+type MockConfiguration struct {
+ OptionStr string `yaml:"option_str" json:"option_str"`
+ OptionInt int `yaml:"option_int" json:"option_int"`
+}
+
+// MockModule MockModule.
+type MockModule struct {
+ Base
+
+ Config MockConfiguration `yaml:",inline" json:""`
+
+ FailOnInit bool
+
+ InitFunc func() error
+ CheckFunc func() error
+ ChartsFunc func() *Charts
+ CollectFunc func() map[string]int64
+ CleanupFunc func()
+ CleanupDone bool
+}
+
+// Init invokes InitFunc.
+func (m *MockModule) Init() error {
+ if m.FailOnInit {
+ return errors.New("mock init error")
+ }
+ if m.InitFunc == nil {
+ return nil
+ }
+ return m.InitFunc()
+}
+
+// Check invokes CheckFunc.
+func (m *MockModule) Check() error {
+ if m.CheckFunc == nil {
+ return nil
+ }
+ return m.CheckFunc()
+}
+
+// Charts invokes ChartsFunc.
+func (m *MockModule) Charts() *Charts {
+ if m.ChartsFunc == nil {
+ return nil
+ }
+ return m.ChartsFunc()
+}
+
+// Collect invokes CollectDunc.
+func (m *MockModule) Collect() map[string]int64 {
+ if m.CollectFunc == nil {
+ return nil
+ }
+ return m.CollectFunc()
+}
+
+// Cleanup sets CleanupDone to true.
+func (m *MockModule) Cleanup() {
+ if m.CleanupFunc != nil {
+ m.CleanupFunc()
+ }
+ m.CleanupDone = true
+}
+
+func (m *MockModule) Configuration() any {
+ return m.Config
+}
diff --git a/src/go/collectors/go.d.plugin/agent/module/mock_test.go b/src/go/collectors/go.d.plugin/agent/module/mock_test.go
new file mode 100644
index 000000000..d7521911f
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/agent/module/mock_test.go
@@ -0,0 +1,54 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package module
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMockModule_Init(t *testing.T) {
+ m := &MockModule{}
+
+ assert.NoError(t, m.Init())
+ m.InitFunc = func() error { return nil }
+ assert.NoError(t, m.Init())
+}
+
+func TestMockModule_Check(t *testing.T) {
+ m := &MockModule{}
+
+ assert.NoError(t, m.Check())
+ m.CheckFunc = func() error { return nil }
+ assert.NoError(t, m.Check())
+}
+
+func TestMockModule_Charts(t *testing.T) {
+ m := &MockModule{}
+ c := &Charts{}
+
+ assert.Nil(t, m.Charts())
+ m.ChartsFunc = func() *Charts { return c }
+ assert.True(t, c == m.Charts())
+}
+
+func TestMockModule_Collect(t *testing.T) {
+ m := &MockModule{}
+ d := map[string]int64{
+ "1": 1,
+ }
+
+ assert.Nil(t, m.Collect())
+ m.CollectFunc = func() map[string]int64 { return d }
+ assert.Equal(t, d, m.Collect())
+}
+
+func TestMockModule_Cleanup(t *testing.T) {
+ m := &MockModule{}
+ require.False(t, m.CleanupDone)
+
+ m.Cleanup()
+ assert.True(t, m.CleanupDone)
+}
diff --git a/src/go/collectors/go.d.plugin/agent/module/module.go b/src/go/collectors/go.d.plugin/agent/module/module.go
new file mode 100644
index 000000000..2ed82b79f
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/agent/module/module.go
@@ -0,0 +1,77 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package module
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/netdata/netdata/go/go.d.plugin/logger"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v2"
+)
+
+// Module is an interface that represents a module.
+type Module interface {
+ // Init does initialization.
+ // If it returns error, the job will be disabled.
+ Init() error
+
+ // Check is called after Init.
+ // If it returns error, the job will be disabled.
+ Check() error
+
+ // Charts returns the chart definition.
+ Charts() *Charts
+
+ // Collect collects metrics.
+ Collect() map[string]int64
+
+ // Cleanup Cleanup
+ Cleanup()
+
+ GetBase() *Base
+
+ Configuration() any
+}
+
+// Base is a helper struct. All modules should embed this struct.
+type Base struct {
+ *logger.Logger
+}
+
+func (b *Base) GetBase() *Base { return b }
+
+func TestConfigurationSerialize(t *testing.T, mod Module, cfgJSON, cfgYAML []byte) {
+ t.Helper()
+ tests := map[string]struct {
+ config []byte
+ unmarshal func(in []byte, out interface{}) (err error)
+ marshal func(in interface{}) (out []byte, err error)
+ }{
+ "json": {config: cfgJSON, marshal: json.Marshal, unmarshal: json.Unmarshal},
+ "yaml": {config: cfgYAML, marshal: yaml.Marshal, unmarshal: yaml.Unmarshal},
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+
+ require.NoError(t, test.unmarshal(test.config, mod), "unmarshal test->mod")
+ bs, err := test.marshal(mod.Configuration())
+ require.NoError(t, err, "marshal mod config")
+
+ var want map[string]any
+ var got map[string]any
+
+ require.NoError(t, test.unmarshal(test.config, &want), "unmarshal test->map")
+ require.NoError(t, test.unmarshal(bs, &got), "unmarshal mod->map")
+
+ require.NotNil(t, want, "want map")
+ require.NotNil(t, got, "got map")
+
+ assert.Equal(t, want, got)
+ })
+ }
+}
diff --git a/src/go/collectors/go.d.plugin/agent/module/registry.go b/src/go/collectors/go.d.plugin/agent/module/registry.go
new file mode 100644
index 000000000..1d2aa9477
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/agent/module/registry.go
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package module
+
+import "fmt"
+
+const (
+ UpdateEvery = 1
+ AutoDetectionRetry = 0
+ Priority = 70000
+)
+
+// Defaults is a set of module default parameters.
+type Defaults struct {
+ UpdateEvery int
+ AutoDetectionRetry int
+ Priority int
+ Disabled bool
+}
+
+type (
+ // Creator is a Job builder.
+ Creator struct {
+ Defaults
+ Create func() Module
+ JobConfigSchema string
+ Config func() any
+ }
+ // Registry is a collection of Creators.
+ Registry map[string]Creator
+)
+
+// DefaultRegistry DefaultRegistry.
+var DefaultRegistry = Registry{}
+
+// Register registers a module in the DefaultRegistry.
+func Register(name string, creator Creator) {
+ DefaultRegistry.Register(name, creator)
+}
+
+// Register registers a module.
+func (r Registry) Register(name string, creator Creator) {
+ if _, ok := r[name]; ok {
+ panic(fmt.Sprintf("%s is already in registry", name))
+ }
+ r[name] = creator
+}
+
+func (r Registry) Lookup(name string) (Creator, bool) {
+ v, ok := r[name]
+ return v, ok
+}
diff --git a/src/go/collectors/go.d.plugin/agent/module/registry_test.go b/src/go/collectors/go.d.plugin/agent/module/registry_test.go
new file mode 100644
index 000000000..c9f31105a
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/agent/module/registry_test.go
@@ -0,0 +1,34 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package module
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRegister(t *testing.T) {
+ modName := "modName"
+ registry := make(Registry)
+
+ // OK case
+ assert.NotPanics(
+ t,
+ func() {
+ registry.Register(modName, Creator{})
+ })
+
+ _, exist := registry[modName]
+
+ require.True(t, exist)
+
+ // Panic case
+ assert.Panics(
+ t,
+ func() {
+ registry.Register(modName, Creator{})
+ })
+
+}