feat(go.d): add generic sql collector (#21281)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Ilya Mashchenko committed
Nov 17, 2025 at 13:54 UTC
e8ec31c390e4751905f16bd4f4e063c6f995b499
14 files changed
+2590
-10
src/go/go.mod
+3
@@ -69,6 +69,7 @@ require (
69
require (
70
github.com/alexbrainman/odbc v0.0.0-20250601004241-49e6b2bc0cf0
71
github.com/ibm-messaging/mq-golang/v5 v5.6.6
72
+ github.com/microsoft/go-mssqldb v1.9.4
73
gopkg.in/yaml.v3 v3.0.1
74
)
75
@@ -100,6 +101,8 @@ require (
101
github.com/go-openapi/jsonreference v0.21.0 // indirect
102
github.com/go-openapi/swag v0.23.0 // indirect
103
github.com/gogo/protobuf v1.3.2 // indirect
104
+ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
105
+ github.com/golang-sql/sqlexp v0.1.0 // indirect
106
github.com/golang/protobuf v1.5.4 // indirect
107
github.com/golang/snappy v0.0.4 // indirect
108
github.com/google/certificate-transparency-go v1.1.7 // indirect
src/go/go.sum
+20
-10
@@ -9,18 +9,22 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
9
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
10
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
11
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
12
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
13
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
14
-github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.1 h1:1mvYtZfWQAnwNah/C+Z+Jb9rQH95LPE2vlmMuWAHJk8=
15
-github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.1/go.mod h1:75I/mXtme1JyWFtz8GocPHVFyH421IBoZErnO16dd0k=
16
-github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY=
17
-github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
12
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
13
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
14
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
15
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
16
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
17
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
18
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
19
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
20
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
21
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
22
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
23
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
24
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
25
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
22
-github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2 h1:kYRSnvJju5gYVyhkij+RTJ/VR6QIUaCfWeaFm2ycsjQ=
23
-github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
26
+github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
27
+github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
28
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
29
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
30
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
@@ -147,8 +151,12 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
151
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
152
github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
153
github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ=
150
-github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
151
-github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
154
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
155
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
156
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
157
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
158
+github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
159
+github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
160
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
161
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
162
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -325,6 +333,8 @@ github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/
333
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
334
github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
335
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
336
+github.com/microsoft/go-mssqldb v1.9.4 h1:sHrj3GcdgkxytZ09aZ3+ys72pMeyEXJowT44j74pNgs=
337
+github.com/microsoft/go-mssqldb v1.9.4/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
338
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
339
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
340
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
src/go/plugin/go.d/collector/init.go
+1
@@ -102,6 +102,7 @@ import (
102
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/smartctl"
103
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp"
104
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/spigotmc"
105
+ _ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/sql"
106
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/squid"
107
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/squidlog"
108
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/storcli"
src/go/plugin/go.d/collector/sql/charts.go
new
+53
@@ -0,0 +1,53 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package sql
4
+
5
+import (
6
+ "fmt"
7
+
8
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
9
+)
10
+
11
+const (
12
+ prioChart = module.Priority + iota
13
+)
14
+
15
+func (c *Collector) createChart(chartID string, m ConfigMetricBlock, ch ConfigChartConfig, row map[string]string) {
16
+ if c.seenCharts[chartID] {
17
+ return
18
+ }
19
+ c.seenCharts[chartID] = true
20
+
21
+ chart := &module.Chart{
22
+ ID: chartID,
23
+ Title: ch.Title,
24
+ Units: ch.Units,
25
+ Type: module.ChartType(ch.Type),
26
+ Ctx: fmt.Sprintf("sql.%s_%s", c.Driver, ch.Context),
27
+ Fam: ch.Family,
28
+ Priority: prioChart,
29
+ }
30
+
31
+ for k, v := range c.StaticLabels {
32
+ chart.Labels = append(chart.Labels, module.Label{Key: k, Value: v})
33
+ }
34
+ chart.Labels = append(chart.Labels, module.Label{Key: "driver", Value: c.Driver})
35
+
36
+ for _, lf := range m.LabelsFromRow {
37
+ if v, ok := row[lf.Source]; ok {
38
+ chart.Labels = append(chart.Labels, module.Label{Key: lf.Name, Value: v})
39
+ }
40
+ }
41
+
42
+ for _, d := range ch.Dims {
43
+ chart.Dims = append(chart.Dims, &module.Dim{
44
+ ID: buildDimID(chartID, d.Name),
45
+ Name: d.Name,
46
+ Algo: module.DimAlgo(ch.Algorithm),
47
+ })
48
+ }
49
+
50
+ if err := c.Charts().Add(chart); err != nil {
51
+ c.Warningf("failed to add chart %q: %v", chartID, err)
52
+ }
53
+}
src/go/plugin/go.d/collector/sql/collect.go
new
+266
@@ -0,0 +1,266 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package sql
4
+
5
+import (
6
+ "context"
7
+ "database/sql"
8
+ "fmt"
9
+ "slices"
10
+ "strconv"
11
+ "strings"
12
+ "time"
13
+
14
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/metrix"
15
+)
16
+
17
+func (c *Collector) collect(ctx context.Context) (map[string]int64, error) {
18
+ if c.db == nil {
19
+ if err := c.openConnection(); err != nil {
20
+ return nil, err
21
+ }
22
+ }
23
+
24
+ qcache, _, err := c.execReusableQueries(ctx)
25
+ if err != nil {
26
+ return nil, err
27
+ }
28
+
29
+ mcache, _, err := c.execMetricQueries(ctx, qcache)
30
+ if err != nil {
31
+ return nil, err
32
+ }
33
+
34
+ mx := make(map[string]int64)
35
+ if err := c.collectMetrics(mx, mcache); err != nil {
36
+ return nil, err
37
+ }
38
+
39
+ return mx, nil
40
+}
41
+
42
+func (c *Collector) collectMetrics(mx map[string]int64, mcache QueryRowsCache) error {
43
+ for i, m := range c.Metrics {
44
+ rows, ok := mcache[m.ID]
45
+ if !ok {
46
+ continue
47
+ }
48
+
49
+ switch strings.ToLower(m.Mode) {
50
+ case "columns", "":
51
+ if err := c.collectMetricsModeColumns(mx, m, rows); err != nil {
52
+ return fmt.Errorf("metric %q (index %d) columns: %w", m.ID, i, err)
53
+ }
54
+ case "kv":
55
+ if err := c.collectMetricsModeKV(mx, m, rows); err != nil {
56
+ return fmt.Errorf("metric %q (index %d) kv: %w", m.ID, i, err)
57
+ }
58
+ default:
59
+ return fmt.Errorf("metric %q (index %d) unknown mode %q", m.ID, i, m.Mode)
60
+ }
61
+ }
62
+ return nil
63
+}
64
+
65
+func (c *Collector) collectMetricsModeColumns(mx map[string]int64, m ConfigMetricBlock, rows []map[string]string) error {
66
+ for _, ch := range m.Charts {
67
+ for _, row := range rows {
68
+ chartID := c.buildChartID(m, ch, row)
69
+ if chartID == "" {
70
+ continue
71
+ }
72
+ c.createChart(chartID, m, ch, row)
73
+
74
+ for _, d := range ch.Dims {
75
+ raw, ok := row[d.Source]
76
+ if !ok {
77
+ continue
78
+ }
79
+ id := buildDimID(chartID, d.Name)
80
+
81
+ if d.StatusWhen == nil {
82
+ mx[id] += toInt64(raw)
83
+ } else if v, ok := mx[id]; !ok || v == 0 {
84
+ mx[id] = metrix.Bool(c.evalStatusWhen(d.StatusWhen, raw))
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ return nil
91
+}
92
+
93
+func (c *Collector) collectMetricsModeKV(mx map[string]int64, m ConfigMetricBlock, rows []map[string]string) error {
94
+ if m.KVMode == nil {
95
+ return nil
96
+ }
97
+
98
+ nameCol := m.KVMode.NameCol
99
+ valCol := m.KVMode.ValueCol
100
+
101
+ for _, ch := range m.Charts {
102
+ for _, row := range rows {
103
+ chartID := c.buildChartID(m, ch, row)
104
+ if chartID == "" {
105
+ continue
106
+ }
107
+ c.createChart(chartID, m, ch, row)
108
+
109
+ k, ok1 := row[nameCol]
110
+ vraw, ok2 := row[valCol]
111
+ if !ok1 || !ok2 {
112
+ continue
113
+ }
114
+
115
+ for _, d := range ch.Dims {
116
+ if d.Source != k {
117
+ continue
118
+ }
119
+ id := buildDimID(chartID, d.Name)
120
+
121
+ if d.StatusWhen == nil {
122
+ mx[id] += toInt64(vraw)
123
+ } else if v, ok := mx[id]; !ok || v == 0 {
124
+ mx[id] = metrix.Bool(c.evalStatusWhen(d.StatusWhen, vraw))
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ return nil
131
+}
132
+
133
+func (c *Collector) evalStatusWhen(sw *ConfigStatusWhen, value string) bool {
134
+ switch {
135
+ case sw.Equals != "":
136
+ return value == sw.Equals
137
+ case len(sw.In) > 0:
138
+ return slices.Contains(sw.In, value)
139
+ case sw.re != nil:
140
+ return sw.re.MatchString(value)
141
+ default:
142
+ return false
143
+ }
144
+}
145
+
146
+func (c *Collector) openConnection() error {
147
+ db, err := sql.Open(c.Driver, c.DSN)
148
+ if err != nil {
149
+ return fmt.Errorf("open %s: %w (dsn=%s)", c.Driver, err, redactDSN(c.DSN))
150
+ }
151
+
152
+ db.SetConnMaxLifetime(time.Minute * 10)
153
+
154
+ ctx, cancel := context.WithTimeout(context.Background(), c.Timeout.Duration())
155
+ defer cancel()
156
+
157
+ if err := db.PingContext(ctx); err != nil {
158
+ _ = db.Close()
159
+ return fmt.Errorf("ping %s: %w (dsn=%s)", c.Driver, err, redactDSN(c.DSN))
160
+ }
161
+
162
+ c.db = db
163
+
164
+ return nil
165
+}
166
+
167
+func (c *Collector) buildChartID(m ConfigMetricBlock, ch ConfigChartConfig, row map[string]string) string {
168
+ var b strings.Builder
169
+ b.Grow(128)
170
+
171
+ b.WriteString(c.Driver + "_" + m.ID + "_" + ch.Context)
172
+
173
+ for _, lf := range m.LabelsFromRow {
174
+ v, ok := row[lf.Source]
175
+ if !ok {
176
+ return ""
177
+ }
178
+ b.WriteString("_" + v)
179
+ }
180
+
181
+ return normalizeID(b.String())
182
+}
183
+
184
+func buildDimID(chartID, dimName string) string {
185
+ return normalizeID(chartID + "." + dimName)
186
+}
187
+
188
+var idReplacer = strings.NewReplacer(" ", "_", ".", "_")
189
+
190
+func normalizeID(id string) string {
191
+ return strings.ToLower(idReplacer.Replace(id))
192
+}
193
+
194
+func redactDSN(dsn string) string {
195
+ if dsn == "" {
196
+ return dsn
197
+ }
198
+
199
+ // Find where authority starts (right after "://", if present)
200
+ authStart := 0
201
+ if i := strings.Index(dsn, "://"); i != -1 {
202
+ authStart = i + 3
203
+ }
204
+
205
+ // Find the *last* '@' after authority start
206
+ rel := strings.LastIndex(dsn[authStart:], "@")
207
+ if rel == -1 {
208
+ // no userinfo
209
+ return dsn
210
+ }
211
+ at := authStart + rel
212
+
213
+ // userinfo is between authority start and '@'
214
+ userinfo := dsn[authStart:at]
215
+ if userinfo == "" {
216
+ // malformed/empty userinfo; leave unchanged
217
+ return dsn
218
+ }
219
+
220
+ // If there's a colon, treat text before first ':' as user and the rest as password.
221
+ if colon := strings.IndexByte(userinfo, ':'); colon >= 0 {
222
+ user := userinfo[:colon]
223
+ // Keep user, redact password
224
+ redacted := user + ":****"
225
+ return dsn[:authStart] + redacted + dsn[at:]
226
+ }
227
+
228
+ // No password present -> redact entire username
229
+ return dsn[:authStart] + "****" + dsn[at:]
230
+}
231
+
232
+func makeRawBytesSlice(size int) []any {
233
+ values := make([]any, size)
234
+ for i := range values {
235
+ var b sql.RawBytes
236
+ values[i] = &b
237
+ }
238
+ return values
239
+}
240
+
241
+func rawBytesToString(value any) string {
242
+ if rb, ok := value.(*sql.RawBytes); ok && rb != nil {
243
+ return string(*rb)
244
+ }
245
+ return ""
246
+}
247
+
248
+// local parser: int -> float -> bool -> 0
249
+func toInt64(s string) int64 {
250
+ if s == "" {
251
+ return 0
252
+ }
253
+ if i, err := strconv.ParseInt(s, 10, 64); err == nil {
254
+ return i
255
+ }
256
+ if f, err := strconv.ParseFloat(s, 64); err == nil {
257
+ return int64(f)
258
+ }
259
+ switch strings.ToLower(strings.TrimSpace(s)) {
260
+ case "true", "t", "yes", "y", "on", "up":
261
+ return 1
262
+ case "false", "f", "no", "n", "off", "down":
263
+ return 0
264
+ }
265
+ return 0
266
+}
src/go/plugin/go.d/collector/sql/collector.go
new
+91
@@ -0,0 +1,91 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package sql
4
+
5
+import (
6
+ "context"
7
+ "database/sql"
8
+ _ "embed"
9
+ "errors"
10
+ "time"
11
+
12
+ "github.com/netdata/netdata/go/plugins/pkg/confopt"
13
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
14
+)
15
+
16
+//go:embed "config_schema.json"
17
+var configSchema string
18
+
19
+func init() {
20
+ module.Register("sql", module.Creator{
21
+ Create: func() module.Module { return New() },
22
+ JobConfigSchema: configSchema,
23
+ Config: func() any { return &Config{} },
24
+ })
25
+}
26
+
27
+func New() *Collector {
28
+ return &Collector{
29
+ Config: Config{
30
+ Driver: "mysql",
31
+ Timeout: confopt.Duration(time.Second * 5),
32
+ },
33
+ charts: &module.Charts{},
34
+ seenCharts: make(map[string]bool),
35
+ skipValues: make(map[string]bool),
36
+ }
37
+}
38
+
39
+type Collector struct {
40
+ module.Base
41
+ Config `yaml:",inline" json:""`
42
+
43
+ charts *module.Charts
44
+
45
+ db *sql.DB
46
+
47
+ seenCharts map[string]bool
48
+ skipValues map[string]bool
49
+}
50
+
51
+func (c *Collector) Configuration() any {
52
+ return c.Config
53
+}
54
+
55
+func (c *Collector) Charts() *module.Charts {
56
+ return c.charts
57
+}
58
+
59
+func (c *Collector) Init(context.Context) error {
60
+ return c.validateConfig()
61
+}
62
+
63
+func (c *Collector) Check(ctx context.Context) error {
64
+ mx, err := c.collect(ctx)
65
+ if err != nil {
66
+ return err
67
+ }
68
+ if len(mx) == 0 {
69
+ return errors.New("no metrics collected")
70
+ }
71
+ return nil
72
+}
73
+
74
+func (c *Collector) Collect(ctx context.Context) map[string]int64 {
75
+ mx, err := c.collect(ctx)
76
+ if err != nil {
77
+ c.Error(err)
78
+ }
79
+
80
+ if len(mx) == 0 {
81
+ return nil
82
+ }
83
+ return mx
84
+}
85
+
86
+func (c *Collector) Cleanup(context.Context) {
87
+ if c.db != nil {
88
+ _ = c.db.Close()
89
+ c.db = nil
90
+ }
91
+}
src/go/plugin/go.d/collector/sql/collector_test.go
new
+681
@@ -0,0 +1,681 @@
1
+package sql
2
+
3
+import (
4
+ "context"
5
+ "os"
6
+ "testing"
7
+
8
+ "github.com/DATA-DOG/go-sqlmock"
9
+ "github.com/stretchr/testify/assert"
10
+ "github.com/stretchr/testify/require"
11
+
12
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
13
+)
14
+
15
+var (
16
+ dataConfigJSON, _ = os.ReadFile("testdata/config.json")
17
+ dataConfigYAML, _ = os.ReadFile("testdata/config.yaml")
18
+)
19
+
20
+func TestCollector_ConfigurationSerialize(t *testing.T) {
21
+ module.TestConfigurationSerialize(t, &Collector{}, dataConfigJSON, dataConfigYAML)
22
+}
23
+
24
+func TestCollector_Charts(t *testing.T) {
25
+ assert.NotNil(t, New().Charts())
26
+}
27
+
28
+func TestCollector_Cleanup(t *testing.T) {
29
+ tests := map[string]func(t *testing.T) (collr *Collector, cleanup func()){
30
+ "db connection not initialized": func(t *testing.T) (collr *Collector, cleanup func()) {
31
+ return New(), func() {}
32
+ },
33
+ "db connection initialized": func(t *testing.T) (collr *Collector, cleanup func()) {
34
+ db, mock, err := sqlmock.New()
35
+ require.NoError(t, err)
36
+
37
+ mock.ExpectClose()
38
+ collr = New()
39
+ collr.db = db
40
+ cleanup = func() { _ = db.Close() }
41
+
42
+ return collr, cleanup
43
+ },
44
+ }
45
+
46
+ for name, prepare := range tests {
47
+ t.Run(name, func(t *testing.T) {
48
+ collr, cleanup := prepare(t)
49
+ defer cleanup()
50
+
51
+ assert.NotPanics(t, func() { collr.Cleanup(context.Background()) })
52
+ assert.Nil(t, collr.db)
53
+ })
54
+ }
55
+}
56
+
57
+func TestCollector_Check(t *testing.T) {
58
+ const query = "SELECT 1 AS value"
59
+
60
+ tests := map[string]struct {
61
+ prepareMock func(t *testing.T, m sqlmock.Sqlmock)
62
+ wantFail bool
63
+ }{
64
+ "success when metrics collected": {
65
+ wantFail: false,
66
+ prepareMock: func(t *testing.T, m sqlmock.Sqlmock) {
67
+ rows := sqlmock.NewRows([]string{"value"}).AddRow("1")
68
+ m.ExpectQuery(query).WillReturnRows(rows).RowsWillBeClosed()
69
+ },
70
+ },
71
+ "error when query fails": {
72
+ wantFail: true,
73
+ prepareMock: func(t *testing.T, m sqlmock.Sqlmock) {
74
+ m.ExpectQuery(query).WillReturnError(assert.AnError)
75
+ },
76
+ },
77
+ }
78
+
79
+ for name, test := range tests {
80
+ t.Run(name, func(t *testing.T) {
81
+ db, mock, err := sqlmock.New(
82
+ sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual),
83
+ )
84
+ require.NoError(t, err)
85
+ defer func() { _ = db.Close() }()
86
+
87
+ collr := New()
88
+ collr.db = db
89
+ collr.Driver = "pgx"
90
+ collr.DSN = "postgres://user:pass@localhost/db"
91
+ collr.Metrics = []ConfigMetricBlock{
92
+ {
93
+ ID: "m1",
94
+ Mode: "columns",
95
+ Query: query,
96
+ Charts: []ConfigChartConfig{
97
+ {
98
+ Title: "test",
99
+ Context: "pg.test",
100
+ Family: "test",
101
+ Units: "events",
102
+ Dims: []ConfigDimConfig{
103
+ {Name: "value", Source: "value"},
104
+ },
105
+ },
106
+ },
107
+ },
108
+ }
109
+
110
+ require.NoError(t, collr.Init(context.Background()))
111
+
112
+ test.prepareMock(t, mock)
113
+
114
+ if test.wantFail {
115
+ assert.Error(t, collr.Check(context.Background()))
116
+ } else {
117
+ assert.NoError(t, collr.Check(context.Background()))
118
+ }
119
+ assert.NoError(t, mock.ExpectationsWereMet())
120
+ })
121
+ }
122
+}
123
+
124
+func TestCollector_Collect(t *testing.T) {
125
+
126
+ type testCase struct {
127
+ prepare func(t *testing.T, mock sqlmock.Sqlmock, coll *Collector)
128
+ check func(t *testing.T, mx map[string]int64)
129
+ }
130
+
131
+ tests := map[string]testCase{
132
+ "columns: metric columns without labels (bgwriter-like)": {
133
+ prepare: func(t *testing.T, mock sqlmock.Sqlmock, coll *Collector) {
134
+ query := `
135
+SELECT
136
+ checkpoints_timed,
137
+ checkpoints_req,
138
+ checkpoint_write_time,
139
+ checkpoint_sync_time,
140
+ buffers_checkpoint_bytes,
141
+ buffers_clean_bytes,
142
+ maxwritten_clean,
143
+ buffers_backend_bytes,
144
+ buffers_backend_fsync,
145
+ buffers_alloc_bytes
146
+`
147
+ rows := sqlmock.NewRows([]string{
148
+ "checkpoints_timed",
149
+ "checkpoints_req",
150
+ "checkpoint_write_time",
151
+ "checkpoint_sync_time",
152
+ "buffers_checkpoint_bytes",
153
+ "buffers_clean_bytes",
154
+ "maxwritten_clean",
155
+ "buffers_backend_bytes",
156
+ "buffers_backend_fsync",
157
+ "buffers_alloc_bytes",
158
+ }).AddRow(
159
+ "1814",
160
+ "16",
161
+ "167",
162
+ "47",
163
+ "32768",
164
+ "0",
165
+ "0",
166
+ "0",
167
+ "0",
168
+ "27295744",
169
+ )
170
+
171
+ mock.ExpectQuery(query).WillReturnRows(rows).RowsWillBeClosed()
172
+
173
+ coll.Driver = "pgx"
174
+ coll.DSN = "postgres://user:pass@localhost/db"
175
+ coll.Metrics = []ConfigMetricBlock{
176
+ {
177
+ ID: "bgwriter",
178
+ Mode: "columns",
179
+ Query: query,
180
+ Charts: []ConfigChartConfig{
181
+ {
182
+ Title: "bgwriter",
183
+ Context: "pg.bgwriter",
184
+ Family: "bgwriter",
185
+ Units: "bytes",
186
+ Dims: []ConfigDimConfig{
187
+ {Name: "checkpoints_timed", Source: "checkpoints_timed"},
188
+ {Name: "checkpoints_req", Source: "checkpoints_req"},
189
+ {Name: "checkpoint_write_time", Source: "checkpoint_write_time"},
190
+ {Name: "checkpoint_sync_time", Source: "checkpoint_sync_time"},
191
+ {Name: "buffers_checkpoint_bytes", Source: "buffers_checkpoint_bytes"},
192
+ {Name: "buffers_clean_bytes", Source: "buffers_clean_bytes"},
193
+ {Name: "maxwritten_clean", Source: "maxwritten_clean"},
194
+ {Name: "buffers_backend_bytes", Source: "buffers_backend_bytes"},
195
+ {Name: "buffers_backend_fsync", Source: "buffers_backend_fsync"},
196
+ {Name: "buffers_alloc_bytes", Source: "buffers_alloc_bytes"},
197
+ },
198
+ },
199
+ },
200
+ },
201
+ }
202
+ },
203
+ check: func(t *testing.T, mx map[string]int64) {
204
+ chartID := "pgx_bgwriter_pg.bgwriter"
205
+
206
+ expected := map[string]int64{
207
+ buildDimID(chartID, "checkpoints_timed"): 1814,
208
+ buildDimID(chartID, "checkpoints_req"): 16,
209
+ buildDimID(chartID, "checkpoint_write_time"): 167,
210
+ buildDimID(chartID, "checkpoint_sync_time"): 47,
211
+ buildDimID(chartID, "buffers_checkpoint_bytes"): 32768,
212
+ buildDimID(chartID, "buffers_clean_bytes"): 0,
213
+ buildDimID(chartID, "maxwritten_clean"): 0,
214
+ buildDimID(chartID, "buffers_backend_bytes"): 0,
215
+ buildDimID(chartID, "buffers_backend_fsync"): 0,
216
+ buildDimID(chartID, "buffers_alloc_bytes"): 27295744,
217
+ }
218
+
219
+ for k, want := range expected {
220
+ got, ok := mx[k]
221
+ require.True(t, ok, "expected metric %s", k)
222
+ assert.EqualValues(t, want, got, "metric %s", k)
223
+ }
224
+ },
225
+ },
226
+
227
+ "columns: metrics as columns with label (datname)": {
228
+ prepare: func(t *testing.T, mock sqlmock.Sqlmock, coll *Collector) {
229
+ query := `
230
+SELECT
231
+ datname,
232
+ confl_tablespace,
233
+ confl_lock,
234
+ confl_snapshot,
235
+ confl_bufferpin,
236
+ confl_deadlock
237
+`
238
+ rows := sqlmock.NewRows([]string{
239
+ "datname",
240
+ "confl_tablespace",
241
+ "confl_lock",
242
+ "confl_snapshot",
243
+ "confl_bufferpin",
244
+ "confl_deadlock",
245
+ }).
246
+ AddRow("postgres", "0", "0", "0", "0", "0").
247
+ AddRow("production", "0", "0", "0", "0", "0")
248
+
249
+ mock.ExpectQuery(query).WillReturnRows(rows).RowsWillBeClosed()
250
+
251
+ coll.Driver = "pgx"
252
+ coll.DSN = "postgres://user:pass@localhost/db"
253
+ coll.Metrics = []ConfigMetricBlock{
254
+ {
255
+ ID: "conflicts",
256
+ Mode: "columns",
257
+ Query: query,
258
+ LabelsFromRow: []ConfigLabelFromRow{
259
+ {Source: "datname", Name: "db"},
260
+ },
261
+ Charts: []ConfigChartConfig{
262
+ {
263
+ Title: "conflicts",
264
+ Context: "pg.conflicts",
265
+ Family: "conflicts",
266
+ Units: "conflicts",
267
+ Dims: []ConfigDimConfig{
268
+ {Name: "confl_tablespace", Source: "confl_tablespace"},
269
+ {Name: "confl_lock", Source: "confl_lock"},
270
+ {Name: "confl_snapshot", Source: "confl_snapshot"},
271
+ {Name: "confl_bufferpin", Source: "confl_bufferpin"},
272
+ {Name: "confl_deadlock", Source: "confl_deadlock"},
273
+ },
274
+ },
275
+ },
276
+ },
277
+ }
278
+ },
279
+ check: func(t *testing.T, mx map[string]int64) {
280
+ chartPostgres := "pgx_conflicts_pg.conflicts_postgres"
281
+ chartProduction := "pgx_conflicts_pg.conflicts_production"
282
+
283
+ keys := []string{
284
+ "confl_tablespace",
285
+ "confl_lock",
286
+ "confl_snapshot",
287
+ "confl_bufferpin",
288
+ "confl_deadlock",
289
+ }
290
+
291
+ for _, dim := range keys {
292
+ k1 := buildDimID(chartPostgres, dim)
293
+ k2 := buildDimID(chartProduction, dim)
294
+
295
+ v1, ok1 := mx[k1]
296
+ v2, ok2 := mx[k2]
297
+
298
+ require.True(t, ok1, "expected metric %s", k1)
299
+ require.True(t, ok2, "expected metric %s", k2)
300
+
301
+ assert.EqualValues(t, 0, v1, "metric %s", k1)
302
+ assert.EqualValues(t, 0, v2, "metric %s", k2)
303
+ }
304
+ },
305
+ },
306
+
307
+ "columns: single column table": {
308
+ prepare: func(t *testing.T, mock sqlmock.Sqlmock, coll *Collector) {
309
+ query := `SELECT extract`
310
+ rows := sqlmock.NewRows([]string{"extract"}).
311
+ AddRow("499906.075943")
312
+
313
+ mock.ExpectQuery(query).WillReturnRows(rows).RowsWillBeClosed()
314
+
315
+ coll.Driver = "pgx"
316
+ coll.DSN = "postgres://user:pass@localhost/db"
317
+ coll.Metrics = []ConfigMetricBlock{
318
+ {
319
+ ID: "uptime",
320
+ Mode: "columns",
321
+ Query: query,
322
+ Charts: []ConfigChartConfig{
323
+ {
324
+ Title: "uptime",
325
+ Context: "pg.uptime",
326
+ Family: "uptime",
327
+ Units: "seconds",
328
+ Dims: []ConfigDimConfig{
329
+ {Name: "extract", Source: "extract"},
330
+ },
331
+ },
332
+ },
333
+ },
334
+ }
335
+ },
336
+ check: func(t *testing.T, mx map[string]int64) {
337
+ chartID := "pgx_uptime_pg.uptime"
338
+ key := buildDimID(chartID, "extract")
339
+
340
+ got, ok := mx[key]
341
+ require.True(t, ok, "expected metric %s", key)
342
+ // 499906.075943 -> 499906 after truncation
343
+ assert.EqualValues(t, 499906, got)
344
+ },
345
+ },
346
+
347
+ "kv: metric names as row keys (state -> count)": {
348
+ prepare: func(t *testing.T, mock sqlmock.Sqlmock, coll *Collector) {
349
+ query := `
350
+SELECT
351
+ state,
352
+ count
353
+`
354
+ rows := sqlmock.NewRows([]string{"state", "count"}).
355
+ AddRow("active", "1").
356
+ AddRow("idle", "14").
357
+ AddRow("idle in transaction", "7").
358
+ AddRow("idle in transaction (aborted)", "1").
359
+ AddRow("fastpath function call", "1").
360
+ AddRow("disabled", "1")
361
+
362
+ mock.ExpectQuery(query).WillReturnRows(rows).RowsWillBeClosed()
363
+
364
+ coll.Driver = "pgx"
365
+ coll.DSN = "postgres://user:pass@localhost/db"
366
+ coll.Metrics = []ConfigMetricBlock{
367
+ {
368
+ ID: "activity_states",
369
+ Mode: "kv",
370
+ Query: query,
371
+ KVMode: &ConfigKVMode{
372
+ NameCol: "state",
373
+ ValueCol: "count",
374
+ },
375
+ Charts: []ConfigChartConfig{
376
+ {
377
+ Title: "activity states",
378
+ Context: "pg.activity_states",
379
+ Family: "activity states",
380
+ Units: "events",
381
+ Dims: []ConfigDimConfig{
382
+ {Name: "active", Source: "active"},
383
+ {Name: "idle", Source: "idle"},
384
+ {Name: "idle_in_transaction", Source: "idle in transaction"},
385
+ {Name: "idle_in_transaction_aborted", Source: "idle in transaction (aborted)"},
386
+ {Name: "fastpath_function_call", Source: "fastpath function call"},
387
+ {Name: "disabled", Source: "disabled"},
388
+ },
389
+ },
390
+ },
391
+ },
392
+ }
393
+ },
394
+ check: func(t *testing.T, mx map[string]int64) {
395
+ chartID := "pgx_activity_states_pg.activity_states"
396
+
397
+ expected := map[string]int64{
398
+ buildDimID(chartID, "active"): 1,
399
+ buildDimID(chartID, "idle"): 14,
400
+ buildDimID(chartID, "idle_in_transaction"): 7,
401
+ buildDimID(chartID, "idle_in_transaction_aborted"): 1,
402
+ buildDimID(chartID, "fastpath_function_call"): 1,
403
+ buildDimID(chartID, "disabled"): 1,
404
+ }
405
+
406
+ for k, want := range expected {
407
+ got, ok := mx[k]
408
+ require.True(t, ok, "expected metric %s", k)
409
+ assert.EqualValues(t, want, got, "metric %s", k)
410
+ }
411
+ },
412
+ },
413
+
414
+ "columns: state-type metric with label from state": {
415
+ prepare: func(t *testing.T, mock sqlmock.Sqlmock, coll *Collector) {
416
+ query := `
417
+SELECT
418
+ datname,
419
+ state,
420
+ xact_running_time,
421
+ query_running_time
422
+`
423
+ rows := sqlmock.NewRows([]string{
424
+ "datname",
425
+ "state",
426
+ "xact_running_time",
427
+ "query_running_time",
428
+ }).
429
+ AddRow("some_db", "idle in transaction", "574.530219", "574.315061").
430
+ AddRow("some_db", "idle in transaction", "574.867167", "574.330322").
431
+ AddRow("postgres", "active", "0.000000", "0.000000").
432
+ AddRow("some_db", "idle in transaction", "574.807256", "574.377105").
433
+ AddRow("some_db", "idle in transaction", "574.680244", "574.357246").
434
+ AddRow("some_db", "idle in transaction", "574.800283", "574.330328").
435
+ AddRow("some_db", "idle in transaction", "574.396730", "574.290165").
436
+ AddRow("some_db", "idle in transaction", "574.665428", "574.337164")
437
+
438
+ mock.ExpectQuery(query).WillReturnRows(rows).RowsWillBeClosed()
439
+
440
+ coll.Driver = "pgx"
441
+ coll.DSN = "postgres://user:pass@localhost/db"
442
+ coll.Metrics = []ConfigMetricBlock{
443
+ {
444
+ ID: "xact_state",
445
+ Mode: "columns",
446
+ Query: query,
447
+ LabelsFromRow: []ConfigLabelFromRow{
448
+ {Source: "state", Name: "state"},
449
+ },
450
+ Charts: []ConfigChartConfig{
451
+ {
452
+ Title: "activity",
453
+ Context: "pg.activity",
454
+ Family: "activity",
455
+ Units: "seconds",
456
+ Dims: []ConfigDimConfig{
457
+ {Name: "xact_running_time", Source: "xact_running_time"},
458
+ {Name: "query_running_time", Source: "query_running_time"},
459
+ },
460
+ },
461
+ },
462
+ },
463
+ }
464
+ },
465
+ check: func(t *testing.T, mx map[string]int64) {
466
+ // Chart instances are split by state label.
467
+ idleChartID := "pgx_xact_state_pg.activity_idle in transaction"
468
+ activeChartID := "pgx_xact_state_pg.activity_active"
469
+
470
+ xactIdleKey := buildDimID(idleChartID, "xact_running_time")
471
+ queryIdleKey := buildDimID(idleChartID, "query_running_time")
472
+ xactActiveKey := buildDimID(activeChartID, "xact_running_time")
473
+ queryActiveKey := buildDimID(activeChartID, "query_running_time")
474
+
475
+ // idle in transaction: aggregated sum over multiple rows -> > 0
476
+ xIdle, ok := mx[xactIdleKey]
477
+ require.True(t, ok, "expected metric %s", xactIdleKey)
478
+ assert.Greater(t, xIdle, int64(0))
479
+
480
+ qIdle, ok := mx[queryIdleKey]
481
+ require.True(t, ok, "expected metric %s", queryIdleKey)
482
+ assert.Greater(t, qIdle, int64(0))
483
+
484
+ // active row has 0 durations.
485
+ xAct, ok := mx[xactActiveKey]
486
+ require.True(t, ok, "expected metric %s", xactActiveKey)
487
+ assert.EqualValues(t, 0, xAct)
488
+
489
+ qAct, ok := mx[queryActiveKey]
490
+ require.True(t, ok, "expected metric %s", queryActiveKey)
491
+ assert.EqualValues(t, 0, qAct)
492
+ },
493
+ },
494
+
495
+ "columns: state-type metric with state mapped to metrics": {
496
+ prepare: func(t *testing.T, mock sqlmock.Sqlmock, coll *Collector) {
497
+ query := `
498
+SELECT
499
+ datname,
500
+ state,
501
+ xact_running_time,
502
+ query_running_time
503
+`
504
+ rows := sqlmock.NewRows([]string{
505
+ "datname",
506
+ "state",
507
+ "xact_running_time",
508
+ "query_running_time",
509
+ }).
510
+ AddRow("some_db", "idle in transaction", "574.530219", "574.315061").
511
+ AddRow("some_db", "idle in transaction", "574.867167", "574.330322").
512
+ AddRow("postgres", "active", "0.000000", "0.000000").
513
+ AddRow("some_db", "idle in transaction", "574.807256", "574.377105").
514
+ AddRow("some_db", "idle in transaction", "574.680244", "574.357246").
515
+ AddRow("some_db", "idle in transaction", "574.800283", "574.330328").
516
+ AddRow("some_db", "idle in transaction", "574.396730", "574.290165").
517
+ AddRow("some_db", "idle in transaction", "574.665428", "574.337164")
518
+
519
+ mock.ExpectQuery(query).WillReturnRows(rows).RowsWillBeClosed()
520
+
521
+ coll.Driver = "pgx"
522
+ coll.DSN = "postgres://user:pass@localhost/db"
523
+ coll.Metrics = []ConfigMetricBlock{
524
+ {
525
+ ID: "xact_state_map",
526
+ Mode: "columns",
527
+ Query: query,
528
+ Charts: []ConfigChartConfig{
529
+ {
530
+ Title: "activity",
531
+ Context: "pg.activity",
532
+ Family: "activity",
533
+ Units: "status",
534
+ Dims: []ConfigDimConfig{
535
+ {
536
+ Name: "state_active",
537
+ Source: "state",
538
+ StatusWhen: &ConfigStatusWhen{
539
+ Equals: "active",
540
+ },
541
+ },
542
+ {
543
+ Name: "state_idle_in_transaction",
544
+ Source: "state",
545
+ StatusWhen: &ConfigStatusWhen{
546
+ Equals: "idle in transaction",
547
+ },
548
+ },
549
+ },
550
+ },
551
+ },
552
+ },
553
+ }
554
+ },
555
+ check: func(t *testing.T, mx map[string]int64) {
556
+ chartID := "pgx_xact_state_map_pg.activity"
557
+
558
+ activeKey := buildDimID(chartID, "state_active")
559
+ idleKey := buildDimID(chartID, "state_idle_in_transaction")
560
+
561
+ active, ok := mx[activeKey]
562
+ require.True(t, ok, "expected metric %s", activeKey)
563
+ assert.EqualValues(t, 1, active)
564
+
565
+ idle, ok := mx[idleKey]
566
+ require.True(t, ok, "expected metric %s", idleKey)
567
+ assert.EqualValues(t, 1, idle)
568
+ },
569
+ },
570
+ }
571
+
572
+ for name, tt := range tests {
573
+ t.Run(name, func(t *testing.T) {
574
+ db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
575
+ require.NoError(t, err)
576
+ defer func() { _ = db.Close() }()
577
+
578
+ coll := New()
579
+ coll.db = db
580
+
581
+ tt.prepare(t, mock, coll)
582
+
583
+ require.NoError(t, coll.Init(context.Background()))
584
+
585
+ mx := coll.Collect(context.Background())
586
+ require.NotNil(t, mx)
587
+
588
+ tt.check(t, mx)
589
+
590
+ assert.NoError(t, mock.ExpectationsWereMet())
591
+ })
592
+ }
593
+}
594
+
595
+func TestRedactDSN(t *testing.T) {
596
+ tests := map[string]struct {
597
+ input string
598
+ expected string
599
+ }{
600
+ "simple user:pass@host": {
601
+ input: "user:password@localhost",
602
+ expected: "user:****@localhost",
603
+ },
604
+ "simple user:pass@host:port": {
605
+ input: "user:password@localhost:5432",
606
+ expected: "user:****@localhost:5432",
607
+ },
608
+ "no credentials just host": {
609
+ input: "localhost:5432",
610
+ expected: "localhost:5432",
611
+ },
612
+ "URL without credentials": {
613
+ input: "postgresql://localhost/dbname",
614
+ expected: "postgresql://localhost/dbname",
615
+ },
616
+ "colon in host but no password": {
617
+ input: "localhost:5432/db",
618
+ expected: "localhost:5432/db",
619
+ },
620
+ "empty string": {
621
+ input: "",
622
+ expected: "",
623
+ },
624
+ "just scheme": {
625
+ input: "postgresql://",
626
+ expected: "postgresql://",
627
+ },
628
+ "simple user@host (no password)": {
629
+ input: "user@localhost",
630
+ expected: "****@localhost",
631
+ },
632
+ "postgresql URL with password": {
633
+ input: "postgresql://user:password@localhost/dbname",
634
+ expected: "postgresql://user:****@localhost/dbname",
635
+ },
636
+ "postgresql URL with password and port": {
637
+ input: "postgresql://user:password@localhost:5432/dbname",
638
+ expected: "postgresql://user:****@localhost:5432/dbname",
639
+ },
640
+ "postgresql URL without password": {
641
+ input: "postgresql://user@localhost/dbname",
642
+ expected: "postgresql://****@localhost/dbname",
643
+ },
644
+ "mysql URL with password": {
645
+ input: "mysql://root:secret@localhost:3306/mydb",
646
+ expected: "mysql://root:****@localhost:3306/mydb",
647
+ },
648
+ "postgres URL with complex password": {
649
+ input: "postgres://admin:p@ss:w0rd@localhost/db",
650
+ expected: "postgres://admin:****@localhost/db",
651
+ },
652
+ "URL with query params": {
653
+ input: "postgresql://user:pass@localhost/db?sslmode=disable",
654
+ expected: "postgresql://user:****@localhost/db?sslmode=disable",
655
+ },
656
+ "user with special chars in password": {
657
+ input: "user:p@ssw0rd!@localhost",
658
+ expected: "user:****@localhost",
659
+ },
660
+ "redis URL": {
661
+ input: "redis://user:password@localhost:6379/0",
662
+ expected: "redis://user:****@localhost:6379/0",
663
+ },
664
+ "mongodb URL": {
665
+ input: "mongodb://admin:secret@localhost:27017/mydb",
666
+ expected: "mongodb://admin:****@localhost:27017/mydb",
667
+ },
668
+ "URL with IP address": {
669
+ input: "postgresql://user:pass@192.168.1.1:5432/db",
670
+ expected: "postgresql://user:****@192.168.1.1:5432/db",
671
+ },
672
+ }
673
+
674
+ for name, tc := range tests {
675
+ t.Run(name, func(t *testing.T) {
676
+ got := redactDSN(tc.input)
677
+
678
+ assert.Equal(t, tc.expected, got)
679
+ })
680
+ }
681
+}
src/go/plugin/go.d/collector/sql/config.go
new
+313
@@ -0,0 +1,313 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package sql
4
+
5
+import (
6
+ "errors"
7
+ "fmt"
8
+ "regexp"
9
+ "strings"
10
+
11
+ "github.com/netdata/netdata/go/plugins/pkg/confopt"
12
+)
13
+
14
+type Config struct {
15
+ UpdateEvery int `yaml:"update_every,omitempty" json:"update_every"`
16
+ AutoDetectionRetry int `yaml:"autodetection_retry,omitempty" json:"autodetection_retry"`
17
+
18
+ Driver string `yaml:"driver" json:"driver"`
19
+ DSN string `yaml:"dsn" json:"dsn"`
20
+ Timeout confopt.Duration `yaml:"timeout" json:"timeout"`
21
+
22
+ StaticLabels map[string]string `yaml:"static_labels,omitempty" json:"static_labels"`
23
+ Queries []ConfigQueryDef `yaml:"queries,omitempty" json:"queries"`
24
+ Metrics []ConfigMetricBlock `yaml:"metrics,omitempty" json:"metrics"`
25
+}
26
+
27
+type (
28
+ ConfigQueryDef struct {
29
+ ID string `yaml:"id" json:"id"`
30
+ Query string `yaml:"query" json:"query"`
31
+ }
32
+
33
+ ConfigMetricBlock struct {
34
+ ID string `yaml:"id" json:"id"`
35
+ QueryRef string `yaml:"query_ref,omitempty" json:"query_ref"`
36
+ Query string `yaml:"query,omitempty" json:"query"`
37
+ Mode string `yaml:"mode" json:"mode"` // "columns" | "kv"
38
+ KVMode *ConfigKVMode `yaml:"kv_mode,omitempty" json:"kv_mode"`
39
+ LabelsFromRow []ConfigLabelFromRow `yaml:"labels_from_row,omitempty" json:"labels_from_row"`
40
+ Charts []ConfigChartConfig `yaml:"charts,omitempty" json:"charts"`
41
+ }
42
+ ConfigKVMode struct {
43
+ NameCol string `yaml:"name_col,omitempty" json:"name_col"`
44
+ ValueCol string `yaml:"value_col,omitempty" json:"value_col"`
45
+ }
46
+ ConfigLabelFromRow struct {
47
+ Source string `yaml:"source" json:"source"`
48
+ Name string `yaml:"name" json:"name"`
49
+ //ValueMap map[string]string `yaml:"value_map" json:"value_map"`
50
+ }
51
+)
52
+
53
+type (
54
+ ConfigChartConfig struct {
55
+ Title string `yaml:"title" json:"title"`
56
+ Context string `yaml:"context" json:"context"`
57
+ Family string `yaml:"family" json:"family"`
58
+ Type string `yaml:"type" json:"type"`
59
+ Units string `yaml:"units" json:"units"`
60
+ Algorithm string `yaml:"algorithm" json:"algorithm"`
61
+ Dims []ConfigDimConfig `yaml:"dims" json:"dims"`
62
+ }
63
+ ConfigDimConfig struct {
64
+ Name string `yaml:"name" json:"name"`
65
+ Source string `yaml:"source" json:"source"`
66
+ StatusWhen *ConfigStatusWhen `yaml:"status_when,omitempty" json:"status_when,omitempty"`
67
+ }
68
+ ConfigStatusWhen struct {
69
+ Equals string `yaml:"equals,omitempty" json:"equals,omitempty"`
70
+ In []string `yaml:"in,omitempty" json:"in,omitempty"`
71
+ Match string `yaml:"match,omitempty" json:"match,omitempty"`
72
+
73
+ re *regexp.Regexp
74
+ }
75
+)
76
+
77
+func (c *Collector) validateConfig() error {
78
+ var errs []error
79
+
80
+ if c.Driver == "" {
81
+ errs = append(errs, errors.New("driver required"))
82
+ } else if !supportedDrivers[c.Driver] {
83
+ errs = append(errs, fmt.Errorf("unsupported driver %q", c.Driver))
84
+ }
85
+ if c.DSN == "" {
86
+ errs = append(errs, errors.New("dsn required"))
87
+ }
88
+
89
+ if len(c.Metrics) == 0 {
90
+ errs = append(errs, errors.New("missing metrics"))
91
+ }
92
+
93
+ queryIdx := map[string]bool{}
94
+ for i := range c.Queries {
95
+ errs = append(errs, c.Queries[i].validate(i, queryIdx)...)
96
+ }
97
+
98
+ for i := range c.Metrics {
99
+ errs = append(errs, c.Metrics[i].validate(i, queryIdx)...)
100
+ }
101
+
102
+ return errors.Join(errs...)
103
+}
104
+
105
+// ---- Per-struct validation helpers ----
106
+
107
+func (q *ConfigQueryDef) validate(idx int, seen map[string]bool) []error {
108
+ var errs []error
109
+ qidx := idx + 1
110
+
111
+ if q.ID == "" {
112
+ errs = append(errs, fmt.Errorf("queries[%d] missing id", qidx))
113
+ }
114
+ if q.Query == "" {
115
+ errs = append(errs, fmt.Errorf("queries[%d] missing query", qidx))
116
+ }
117
+
118
+ if q.ID != "" {
119
+ if _, dup := seen[q.ID]; dup {
120
+ errs = append(errs, fmt.Errorf("queries[%d] duplicate id %q", qidx, q.ID))
121
+ }
122
+ seen[q.ID] = true
123
+ }
124
+
125
+ return errs
126
+}
127
+
128
+func (m *ConfigMetricBlock) validate(idx int, queryIdx map[string]bool) []error {
129
+ var errs []error
130
+ midx := idx + 1
131
+
132
+ if m.ID == "" {
133
+ errs = append(errs, fmt.Errorf("metrics[%d] missing id", midx))
134
+ }
135
+
136
+ hasRef := strings.TrimSpace(m.QueryRef) != ""
137
+ hasInline := strings.TrimSpace(m.Query) != ""
138
+ switch {
139
+ case hasRef && hasInline:
140
+ errs = append(errs, fmt.Errorf("metrics[%d] must set exactly one of query_ref or query, not both", midx))
141
+ case !hasRef && !hasInline:
142
+ errs = append(errs, fmt.Errorf("metrics[%d] must set exactly one of query_ref or query", midx))
143
+ case hasRef:
144
+ if _, ok := queryIdx[m.QueryRef]; !ok {
145
+ errs = append(errs, fmt.Errorf("metrics[%d] query_ref %q not found in queries", midx, m.QueryRef))
146
+ }
147
+ }
148
+
149
+ mode := strings.ToLower(strings.TrimSpace(m.Mode))
150
+ switch mode {
151
+ case "kv":
152
+ if m.KVMode == nil {
153
+ errs = append(errs, fmt.Errorf("metrics[%d] kv mode requires kv_mode to be defined", midx))
154
+ } else {
155
+ if strings.TrimSpace(m.KVMode.NameCol) == "" {
156
+ errs = append(errs, fmt.Errorf("metrics[%d] kv_mode.name_col is required in kv mode", midx))
157
+ }
158
+ if strings.TrimSpace(m.KVMode.ValueCol) == "" {
159
+ errs = append(errs, fmt.Errorf("metrics[%d] kv_mode.value_col is required in kv mode", midx))
160
+ }
161
+ if strings.EqualFold(m.KVMode.NameCol, m.KVMode.ValueCol) {
162
+ errs = append(errs, fmt.Errorf("metrics[%d] kv_mode.name_col must differ from kv_mode.value_col", midx))
163
+ }
164
+ }
165
+ case "columns", "":
166
+ default:
167
+ errs = append(errs, fmt.Errorf("metrics[%d] invalid mode %q (expected: columns|kv)", midx, m.Mode))
168
+ }
169
+
170
+ labelCols := map[string]bool{}
171
+ for j := range m.LabelsFromRow {
172
+ errs = append(errs, m.LabelsFromRow[j].validate(midx, j, labelCols)...)
173
+ }
174
+
175
+ if len(m.Charts) == 0 {
176
+ errs = append(errs, fmt.Errorf("metrics[%d].%s missing charts", midx, m.ID))
177
+ }
178
+
179
+ for k := range m.Charts {
180
+ errs = append(errs, m.Charts[k].validate(idx, m.ID, k, mode, labelCols, m.KVMode)...)
181
+ }
182
+
183
+ return errs
184
+}
185
+
186
+func (lf *ConfigLabelFromRow) validate(metricIdx, lfIdx int, labelCols map[string]bool) []error {
187
+ var errs []error
188
+ midx := metricIdx + 1
189
+ lidx := lfIdx + 1
190
+
191
+ if lf.Source == "" {
192
+ errs = append(errs, fmt.Errorf("metrics[%d].labels_from_row[%d] missing source", midx, lidx))
193
+ }
194
+ if lf.Name == "" {
195
+ errs = append(errs, fmt.Errorf("metrics[%d].labels_from_row[%d] missing name", midx, lidx))
196
+ }
197
+ if lf.Source != "" {
198
+ labelCols[strings.ToLower(lf.Source)] = true
199
+ }
200
+
201
+ return errs
202
+}
203
+
204
+func (ch *ConfigChartConfig) validate(metricIdx int, metricID string, chartIdx int, mode string, labelCols map[string]bool, kv *ConfigKVMode) []error {
205
+ var errs []error
206
+ midx := metricIdx + 1
207
+ cidx := chartIdx + 1
208
+
209
+ if strings.TrimSpace(ch.Context) == "" {
210
+ errs = append(errs, fmt.Errorf("metrics[%d].charts[%d] missing context", midx, cidx))
211
+ }
212
+ if strings.TrimSpace(ch.Title) == "" {
213
+ errs = append(errs, fmt.Errorf("metrics[%d].charts[%d] missing title", midx, cidx))
214
+ }
215
+ if strings.TrimSpace(ch.Family) == "" {
216
+ errs = append(errs, fmt.Errorf("metrics[%d].charts[%d] missing family", midx, cidx))
217
+ }
218
+ if strings.TrimSpace(ch.Units) == "" {
219
+ errs = append(errs, fmt.Errorf("metrics[%d].charts[%d] missing units", midx, cidx))
220
+ }
221
+
222
+ seenDims := map[string]bool{}
223
+ for d := range ch.Dims {
224
+ errs = append(errs, ch.Dims[d].validate(metricIdx, metricID, chartIdx, d, mode, labelCols, kv, seenDims)...)
225
+ }
226
+
227
+ return errs
228
+}
229
+
230
+func (dm *ConfigDimConfig) validate(
231
+ metricIdx int,
232
+ metricID string,
233
+ chartIdx int,
234
+ dimIdx int,
235
+ mode string,
236
+ labelCols map[string]bool,
237
+ kv *ConfigKVMode,
238
+ seenDims map[string]bool,
239
+) []error {
240
+ var errs []error
241
+ midx := metricIdx + 1
242
+ cidx := chartIdx + 1
243
+ didx := dimIdx + 1
244
+
245
+ if strings.TrimSpace(dm.Name) == "" {
246
+ errs = append(errs, fmt.Errorf("metrics[%d].charts[%d].dims[%d] missing name", midx, cidx, didx))
247
+ }
248
+ if strings.TrimSpace(dm.Source) == "" {
249
+ errs = append(errs, fmt.Errorf("metrics[%d].charts[%d].dims[%d] missing source", midx, cidx, didx))
250
+ }
251
+
252
+ // unique dim names (case-insensitive)
253
+ if dm.Name != "" {
254
+ key := strings.ToLower(dm.Name)
255
+ if _, ok := seenDims[key]; ok {
256
+ errs = append(errs, fmt.Errorf("metrics[%d].charts[%d] duplicate dim name %q", midx, cidx, dm.Name))
257
+ } else {
258
+ seenDims[key] = true
259
+ }
260
+ }
261
+
262
+ if dm.StatusWhen != nil {
263
+ errs = append(errs, dm.StatusWhen.validate(metricIdx, metricID, chartIdx, dimIdx)...)
264
+ }
265
+
266
+ switch mode {
267
+ case "columns":
268
+ if _, clash := labelCols[strings.ToLower(dm.Source)]; clash {
269
+ errs = append(errs, fmt.Errorf("metrics[%d].charts[%d].dims[%d] source %q conflicts with labels_from_row source", midx, cidx, didx, dm.Source))
270
+ }
271
+ case "kv":
272
+ // In kv mode, dim.source must be a KEY name (not a column).
273
+ if kv != nil && strings.EqualFold(dm.Source, kv.ValueCol) {
274
+ errs = append(errs, fmt.Errorf("metrics[%d].charts[%d].dims[%d] source %q equals kv_mode.value_col; dim.source must be a KEY name", midx, cidx, didx, dm.Source))
275
+ }
276
+ }
277
+
278
+ return errs
279
+}
280
+
281
+func (sw *ConfigStatusWhen) validate(metricIdx int, metricID string, chartIdx, dimIdx int) []error {
282
+ var errs []error
283
+ midx := metricIdx + 1
284
+ cidx := chartIdx + 1
285
+ didx := dimIdx + 1
286
+
287
+ count := 0
288
+ if sw.Equals != "" {
289
+ count++
290
+ }
291
+ if len(sw.In) > 0 {
292
+ count++
293
+ }
294
+ if strings.TrimSpace(sw.Match) != "" {
295
+ re, err := regexp.Compile(sw.Match)
296
+ if err != nil {
297
+ errs = append(errs, fmt.Errorf("invalid regex in status_when.match for metric %q: %w", metricID, err))
298
+ } else {
299
+ // store compiled regex for later use
300
+ sw.re = re
301
+ }
302
+ count++
303
+ }
304
+
305
+ if count == 0 {
306
+ errs = append(errs, fmt.Errorf("metrics[%d].charts[%d].dims[%d].status_when must have exactly one of equals|in|match", midx, cidx, didx))
307
+ }
308
+ if count > 1 {
309
+ errs = append(errs, fmt.Errorf("metrics[%d].charts[%d].dims[%d].status_when must not set multiple selectors", midx, cidx, didx))
310
+ }
311
+
312
+ return errs
313
+}
src/go/plugin/go.d/collector/sql/config_schema.json
new
+401
@@ -0,0 +1,401 @@
1
+{
2
+ "jsonSchema": {
3
+ "$schema": "http://json-schema.org/draft-07/schema#",
4
+ "title": "SQL collector configuration.",
5
+ "type": "object",
6
+ "properties": {
7
+ "update_every": {
8
+ "title": "Update every",
9
+ "description": "How often to collect metrics from the database, in seconds.",
10
+ "type": "integer",
11
+ "minimum": 1,
12
+ "default": 1
13
+ },
14
+ "autodetection_retry": {
15
+ "title": "Detection retry",
16
+ "description": "How often Netdata retries failed automatic detection jobs, in seconds. Set to 0 to disable retries.",
17
+ "type": "integer",
18
+ "minimum": 0,
19
+ "default": 0
20
+ },
21
+ "driver": {
22
+ "title": "Driver",
23
+ "description": "SQL driver / database engine to use. Supported values: `mysql` (MySQL/MariaDB), `pgx` (PostgreSQL), `oracle` (OracleDB), `sqlserver` (Microsoft SQL Server).",
24
+ "type": "string",
25
+ "enum": [
26
+ "mysql",
27
+ "pgx",
28
+ "oracle",
29
+ "sqlserver"
30
+ ],
31
+ "default": "mysql"
32
+ },
33
+ "dsn": {
34
+ "title": "DSN",
35
+ "description": "Database connection string (DSN). The format depends on the selected driver.",
36
+ "type": "string"
37
+ },
38
+ "timeout": {
39
+ "title": "Timeout",
40
+ "description": "Maximum time allowed for a single query and connection check, in seconds.",
41
+ "type": "number",
42
+ "minimum": 0,
43
+ "default": 5
44
+ },
45
+ "static_labels": {
46
+ "title": "Static labels",
47
+ "description": "Key/value labels automatically added to every chart created by this job (for example `env=prod`, `region=eu-west`).",
48
+ "type": "object",
49
+ "additionalProperties": {
50
+ "type": "string"
51
+ }
52
+ },
53
+ "queries": {
54
+ "title": "Reusable queries",
55
+ "description": "Optional list of reusable SQL queries that metric blocks can reference via `query_ref` to avoid repeating SQL text.",
56
+ "type": "array",
57
+ "items": {
58
+ "title": "Query",
59
+ "type": "object",
60
+ "properties": {
61
+ "id": {
62
+ "title": "ID",
63
+ "description": "Unique query identifier referenced from metric blocks using `query_ref` within this job.",
64
+ "type": "string"
65
+ },
66
+ "query": {
67
+ "title": "Query",
68
+ "description": "SQL statement text executed when this query is referenced.",
69
+ "type": "string"
70
+ }
71
+ },
72
+ "required": [
73
+ "id",
74
+ "query"
75
+ ]
76
+ }
77
+ },
78
+ "metrics": {
79
+ "title": "Metric blocks",
80
+ "description": "List of metric blocks. Each block runs one query and maps its result set into one or more Netdata charts.",
81
+ "type": "array",
82
+ "items": {
83
+ "title": "Metric block",
84
+ "type": "object",
85
+ "properties": {
86
+ "id": {
87
+ "title": "ID",
88
+ "description": "Metric block identifier. Must be unique within this job.",
89
+ "type": "string"
90
+ },
91
+ "query_ref": {
92
+ "title": "Query ref",
93
+ "description": "Reference to a reusable query defined in `queries`. Exactly one of `query_ref` or `query` must be set.",
94
+ "type": "string"
95
+ },
96
+ "query": {
97
+ "title": "Inline query",
98
+ "description": "Inline SQL statement for this metric block. Exactly one of `query_ref` or `query` must be set.",
99
+ "type": "string"
100
+ },
101
+ "mode": {
102
+ "title": "Mode",
103
+ "description": "How to interpret the result rows. `columns` treats selected columns as metrics. `kv` treats one column as metric name (key) and another as its value.",
104
+ "type": "string",
105
+ "enum": [
106
+ "columns",
107
+ "kv"
108
+ ],
109
+ "default": "columns"
110
+ },
111
+ "labels_from_row": {
112
+ "title": "Labels from row",
113
+ "description": "Optional labels derived from result columns. Each unique combination of label values produces a separate chart instance.",
114
+ "type": "array",
115
+ "items": {
116
+ "title": "Label from row",
117
+ "type": "object",
118
+ "properties": {
119
+ "source": {
120
+ "title": "Source column",
121
+ "description": "Name of the result column whose value will be used as the label value.",
122
+ "type": "string"
123
+ },
124
+ "name": {
125
+ "title": "Label key",
126
+ "description": "Label key/name added to the chart (for example `db`, `schema`, `table`).",
127
+ "type": "string"
128
+ }
129
+ },
130
+ "required": [
131
+ "source",
132
+ "name"
133
+ ]
134
+ }
135
+ },
136
+ "charts": {
137
+ "title": "Charts",
138
+ "description": "One or more Netdata charts generated from this metric block.",
139
+ "type": "array",
140
+ "items": {
141
+ "title": "Chart",
142
+ "type": "object",
143
+ "properties": {
144
+ "title": {
145
+ "title": "Title",
146
+ "description": "Human-friendly chart title shown in Netdata dashboards.",
147
+ "type": "string"
148
+ },
149
+ "context": {
150
+ "title": "Context",
151
+ "description": "Base metric context name for the chart. The collector will prefix it as `sql.<driver>_<context>` to form the full Netdata context. Contexts define the metric namespace, control alert templates, and group charts across jobs and nodes.",
152
+ "type": "string"
153
+ },
154
+ "family": {
155
+ "title": "Family",
156
+ "description": "Chart family used to organize charts in the Netdata UI. You can use '/' to create hierarchical grouping levels (e.g. `Database/Connections/Active`). Each level becomes a nested section in the dashboard.",
157
+ "type": "string"
158
+ },
159
+ "type": {
160
+ "title": "Type",
161
+ "description": "Visual type of the chart.",
162
+ "type": "string",
163
+ "enum": [
164
+ "line",
165
+ "stacked",
166
+ "area"
167
+ ],
168
+ "default": "line"
169
+ },
170
+ "units": {
171
+ "title": "Units",
172
+ "description": "Unit label for all dimensions in this chart (for example `queries/s`, `bytes`, `connections`).",
173
+ "type": "string"
174
+ },
175
+ "algorithm": {
176
+ "title": "Algorithm",
177
+ "description": "How Netdata interprets values: `absolute` for values that are already final, `incremental` for monotonically increasing counters.",
178
+ "type": "string",
179
+ "enum": [
180
+ "absolute",
181
+ "incremental"
182
+ ],
183
+ "default": "absolute"
184
+ },
185
+ "dims": {
186
+ "title": "Dimensions",
187
+ "description": "Definitions of the metrics (dimensions) that belong to this chart.",
188
+ "type": "array",
189
+ "items": {
190
+ "title": "Dimension",
191
+ "type": "object",
192
+ "properties": {
193
+ "name": {
194
+ "title": "Name",
195
+ "description": "Dimension identifier, unique within this chart. Also used as the legend name.",
196
+ "type": "string"
197
+ },
198
+ "source": {
199
+ "title": "Source",
200
+ "description": "In `columns` mode: the numeric result column to read. In `kv` mode: the key name that must match a value in `kv_mode.name_col`.",
201
+ "type": "string"
202
+ },
203
+ "status_when": {
204
+ "title": "Status when",
205
+ "description": "Optional condition that turns this dimension into a boolean 0/1 status metric. The dimension is 1 if any row matches the condition, otherwise 0.",
206
+ "type": "object",
207
+ "properties": {
208
+ "equals": {
209
+ "title": "Equals",
210
+ "description": "Use 1 when the value is exactly equal to this literal, otherwise 0. Only one of `equals`, `in`, or `match` may be set.",
211
+ "type": "string"
212
+ },
213
+ "in": {
214
+ "title": "In",
215
+ "description": "Use 1 when the value is equal to any element in this list, otherwise 0. Only one of `equals`, `in`, or `match` may be set.",
216
+ "type": "array",
217
+ "items": {
218
+ "type": "string"
219
+ }
220
+ },
221
+ "match": {
222
+ "title": "Match (regex)",
223
+ "description": "Use 1 when the value matches this regular expression, otherwise 0. Only one of `equals`, `in`, or `match` may be set.",
224
+ "type": "string"
225
+ }
226
+ }
227
+ }
228
+ },
229
+ "required": [
230
+ "name",
231
+ "source"
232
+ ]
233
+ }
234
+ }
235
+ },
236
+ "required": [
237
+ "title",
238
+ "context",
239
+ "family",
240
+ "units",
241
+ "dims"
242
+ ]
243
+ }
244
+ }
245
+ },
246
+ "required": [
247
+ "id",
248
+ "mode",
249
+ "charts"
250
+ ],
251
+ "dependencies": {
252
+ "mode": {
253
+ "oneOf": [
254
+ {
255
+ "properties": {
256
+ "mode": {
257
+ "const": "columns"
258
+ }
259
+ }
260
+ },
261
+ {
262
+ "properties": {
263
+ "mode": {
264
+ "const": "kv"
265
+ },
266
+ "kv_mode": {
267
+ "title": "KV mode",
268
+ "description": "Configuration for `kv` mode that tells the collector which columns hold the key and value.",
269
+ "type": "object",
270
+ "properties": {
271
+ "name_col": {
272
+ "title": "Name column",
273
+ "description": "Name of the result column that contains metric keys (for example `state`).",
274
+ "type": "string"
275
+ },
276
+ "value_col": {
277
+ "title": "Value column",
278
+ "description": "Name of the result column that contains numeric metric values for each key.",
279
+ "type": "string"
280
+ }
281
+ }
282
+ }
283
+ },
284
+ "required": [
285
+ "kv_mode"
286
+ ]
287
+ }
288
+ ]
289
+ }
290
+ }
291
+ }
292
+ },
293
+ "vnode": {
294
+ "title": "Vnode",
295
+ "description": "Name of the Virtual Node this job should send its metrics to. Leave empty to use the local node.",
296
+ "type": "string"
297
+ }
298
+ },
299
+ "required": [
300
+ "driver",
301
+ "dsn"
302
+ ]
303
+ },
304
+ "uiSchema": {
305
+ "uiOptions": {
306
+ "fullPage": true
307
+ },
308
+ "ui:flavour": "tabs",
309
+ "ui:options": {
310
+ "tabs": [
311
+ {
312
+ "title": "Base",
313
+ "fields": [
314
+ "update_every",
315
+ "autodetection_retry",
316
+ "driver",
317
+ "dsn",
318
+ "timeout",
319
+ "vnode"
320
+ ]
321
+ },
322
+ {
323
+ "title": "Metrics",
324
+ "fields": [
325
+ "metrics"
326
+ ]
327
+ },
328
+ {
329
+ "title": "Reusable Queries",
330
+ "fields": [
331
+ "queries"
332
+ ]
333
+ },
334
+ {
335
+ "title": "Static Labels",
336
+ "fields": [
337
+ "static_labels"
338
+ ]
339
+ }
340
+ ]
341
+ },
342
+ "vnode": {
343
+ "ui:placeholder": "To use this option, first create a Virtual Node and then reference its name here."
344
+ },
345
+ "autodetection_retry": {
346
+ "ui:help": "This option determines how frequently (in seconds) Netdata will retry data collection jobs that failed initially; setting it to 0 disables this retry mechanism entirely."
347
+ },
348
+ "driver": {
349
+ "ui:widget": "radio",
350
+ "ui:options": {
351
+ "inline": true
352
+ }
353
+ },
354
+ "dsn": {
355
+ "ui:placeholder": "Enter the database connection string",
356
+ "ui:help": "**DSN format depends on the selected driver.**\n\n### MySQL / MariaDB\nFormat: `user:password@tcp(host:port)/dbname?param1=value1¶mN=valueN`\nExample:\n```\nroot:pass@tcp(127.0.0.1:3306)/\n```\n[MySQL DSN documentation](https://github.com/go-sql-driver/mysql#dsn-data-source-name)\n\n### PostgreSQL\nFormats: `postgresql://user:password@host:5432/dbname?param1=value1¶mN=valueN`\nExample:\n```\npostgresql://user:pass@localhost:5432/mydb\n```\n[PostgreSQL DSN documentation](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS)\n\n### Oracle\nFormat: `oracle://username:password@host:port/service?param1=value1¶mN=valueN`\nExample:\n```\noracle://scott:tiger@db.example.com:1521/ORCLPDB1?ssl=off\n```\n\n### SQL Server\nFormat: `sqlserver://username:password@host:port?database=dbname¶m=value`\nExample:\n```\nsqlserver://sa:myStrong(!)Password@localhost:1433?database=master&connection+timeout=30\n```\n[SQL Server driver DSN documentation](https://github.com/denisenkom/go-mssqldb#connection-parameters-and-dsn)"
357
+ },
358
+ "static_labels": {
359
+ "ui:help": "Optional key/value labels added to every chart created by this job (for example env=prod, role=primary)."
360
+ },
361
+ "queries": {
362
+ "items": {
363
+ "query": {
364
+ "ui:widget": "textarea"
365
+ }
366
+ }
367
+ },
368
+ "metrics": {
369
+ "items": {
370
+ "ui:order": [
371
+ "id",
372
+ "query_ref",
373
+ "query",
374
+ "mode",
375
+ "kv_mode",
376
+ "labels_from_row",
377
+ "charts"
378
+ ],
379
+ "mode": {
380
+ "ui:widget": "radio",
381
+ "ui:options": {
382
+ "inline": true
383
+ }
384
+ },
385
+ "query": {
386
+ "ui:widget": "textarea"
387
+ },
388
+ "charts": {
389
+ "items": {
390
+ "type": {
391
+ "ui:widget": "radio",
392
+ "ui:options": {
393
+ "inline": true
394
+ }
395
+ }
396
+ }
397
+ }
398
+ }
399
+ }
400
+ }
401
+}
src/go/plugin/go.d/collector/sql/driver.go
new
+17
@@ -0,0 +1,17 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package sql
4
+
5
+import (
6
+ _ "github.com/go-sql-driver/mysql"
7
+ _ "github.com/jackc/pgx/v5/stdlib"
8
+ _ "github.com/microsoft/go-mssqldb"
9
+ _ "github.com/sijms/go-ora/v2"
10
+)
11
+
12
+var supportedDrivers = map[string]bool{
13
+ "mysql": true,
14
+ "oracle": true,
15
+ "pgx": true,
16
+ "sqlserver": true,
17
+}
src/go/plugin/go.d/collector/sql/metadata.yaml
new
+529
@@ -0,0 +1,529 @@
1
+plugin_name: go.d.plugin
2
+modules:
3
+ - meta:
4
+ id: collector-go.d.plugin-sql
5
+ plugin_name: go.d.plugin
6
+ module_name: sql
7
+ monitored_instance:
8
+ name: SQL databases (generic)
9
+ link: https://en.wikipedia.org/wiki/SQL
10
+ categories:
11
+ - data-collection.database-servers
12
+ icon_filename: sql.svg
13
+ related_resources:
14
+ integrations:
15
+ list: []
16
+ alternative_monitored_instances: []
17
+ info_provided_to_referring_integrations:
18
+ description: ""
19
+ keywords:
20
+ - db
21
+ - database
22
+ - sql
23
+ - mysql
24
+ - maria
25
+ - postgres
26
+ - postgresql
27
+ - pgx
28
+ - oracle
29
+ - sqlserver
30
+ - mssql
31
+ - generic
32
+ most_popular: false
33
+ overview:
34
+ multi_instance: true
35
+ data_collection:
36
+ metrics_description: |
37
+ Metrics and charts for this collector are **entirely defined by your SQL
38
+ configuration**. There is no fixed metric reference: each job can expose
39
+ different metrics depending on its `metrics` and `queries` blocks.
40
+
41
+ To see what a specific job collects, open that job’s dashboard in Netdata
42
+ and inspect the charts and dimensions it created.
43
+
44
+ :::tip
45
+
46
+ To change what is collected, edit the `metrics` (and optional `queries`)
47
+ in the job configuration. After you save the changes, the updated set of
48
+ charts and metrics is reflected in Netdata after the next data collection.
49
+
50
+ :::
51
+ method_description: |
52
+ The collector connects to your database using Go’s **database/sql** package
53
+ and the selected driver:
54
+
55
+ - `mysql` — MySQL / MariaDB
56
+ - `pgx` — PostgreSQL
57
+ - `oracle` — Oracle Database
58
+ - `sqlserver` — Microsoft SQL Server / Azure SQL
59
+
60
+ For each metric block you define, it executes the SQL query (inline or via
61
+ `query_ref`), reads the result set, and maps it to Netdata charts and
62
+ dimensions.
63
+
64
+ ### Result Processing Modes
65
+
66
+ | Mode | How it works | Best used when |
67
+ |------------|------------------------------------------------------------------------------|-----------------------------------------------------|
68
+ | **columns**| Specific numeric columns from each row become dimensions on your charts. | The result set has stable, known column names. |
69
+ | **kv** | One column provides metric names (keys) and another provides their values. | The set of metrics is dynamic or key–value shaped. |
70
+ default_behavior:
71
+ auto_detection:
72
+ description: |
73
+ This is a **generic collector** and does **not** perform automatic detection.
74
+
75
+ It does not create any jobs on its own — you must configure at least one
76
+ job before it can collect data.
77
+ limits:
78
+ description: |
79
+ There are no built-in limits on the number of queries or rows processed.
80
+ However, each metric block must define at least one chart, and each chart
81
+ must define at least one dimension.
82
+
83
+ Keep your queries lightweight and scoped to the data you actually need
84
+ to avoid adding load on the database server.
85
+ performance_impact:
86
+ description: |
87
+ Performance impact depends entirely on the queries you configure and the
88
+ collection frequency (update_every).
89
+
90
+ Prefer indexed reads, avoid full table scans or heavy aggregations, and
91
+ consider using database views tailored for monitoring.
92
+ additional_permissions:
93
+ description: ""
94
+ supported_platforms:
95
+ include: []
96
+ exclude: []
97
+ setup:
98
+ prerequisites:
99
+ list:
100
+ - title: Create a read-only database user
101
+ description: |
102
+ Create a dedicated user for Netdata with read-only privileges on the
103
+ views/tables used in your monitoring queries.
104
+
105
+ For example, on a typical RDBMS you would:
106
+
107
+ - Create a user.
108
+ - Grant SELECT on system metrics views or monitoring views.
109
+
110
+ After creating the user and updating the configuration, restart the
111
+ Netdata Agent with `sudo systemctl restart netdata`, or the appropriate
112
+ method for your system.
113
+ - title: Allow Netdata to connect to the database
114
+ description: |
115
+ Ensure the Netdata host can reach the database via the configured DSN,
116
+ either using:
117
+
118
+ - a local UNIX/TCP socket, or
119
+ - a network connection (hostname/IP and port).
120
+
121
+ If the database is remote, make sure any firewalls or security groups
122
+ allow connections from the Netdata node.
123
+ configuration:
124
+ file:
125
+ name: go.d/sql.conf
126
+ options:
127
+ description: |
128
+ **Full Configuration Structure**
129
+
130
+ ```yaml
131
+ # ---------- CONNECTION ----------
132
+ driver: <mysql|pgx|oracle|sqlserver> # REQUIRED. SQL driver.
133
+ dsn: "<connection string>" # REQUIRED. Driver-specific DSN/URL.
134
+
135
+ # Optional connection settings
136
+ timeout: <seconds> # OPTIONAL. Query timeout.
137
+
138
+ # Optional static labels applied to all charts
139
+ static_labels:
140
+ <label_key1>: <label_value>
141
+ <label_key2>: <label_value>
142
+
143
+ # ---------- REUSABLE QUERIES ----------
144
+ # Optional. Define reusable SQL queries referenced later via query_ref.
145
+ queries:
146
+ - id: <query_id>
147
+ query: |
148
+ SELECT ...
149
+
150
+ # ---------- METRICS ----------
151
+ # Each metric block runs one query and generates one or more charts.
152
+ metrics:
153
+ - id: <metric_block_id> # REQUIRED. Unique within this job.
154
+
155
+ # Choose ONE of these:
156
+ query_ref: <query_id> # Use a reusable query
157
+ # OR
158
+ # query: | # Inline SQL
159
+ # SELECT ...
160
+
161
+ mode: <columns|kv> # REQUIRED. How to interpret result rows.
162
+
163
+ # KV mode settings (only when mode: kv)
164
+ kv_mode:
165
+ name_col: <column_name> # Column containing keys
166
+ value_col: <column_name> # Column containing numeric values
167
+
168
+ # Optional: derive labels from row columns (creates per-label charts)
169
+ labels_from_row:
170
+ - source: <column_name> # Column name from result set
171
+ name: <label_key> # Label key exposed to Netdata
172
+ - source: <column_name>
173
+ name: <label_key>
174
+
175
+ # Charts produced by this metric block
176
+ charts:
177
+ - title: "<Chart Title>" # REQUIRED. Shown in dashboards.
178
+ context: "<context.name>" # REQUIRED. Netdata context.
179
+ family: "<family>" # REQUIRED. Netdata chart family.
180
+ units: "<units>" # REQUIRED. Unit string for the chart.
181
+ type: <line|stacked|area> # OPTIONAL. Default: line.
182
+ algorithm: <absolute|incremental> # OPTIONAL. Default: absolute.
183
+
184
+ dims:
185
+ # ---- COLUMNS MODE DIM ----
186
+ # In mode: columns, `source` MUST be a numeric COLUMN name from the result set.
187
+ - name: <dim_id> # REQUIRED. Dimension id (unique within this chart).
188
+ source: <column_name> # REQUIRED. Numeric column to chart.
189
+
190
+ # ---- KV MODE DIM ----
191
+ # In mode: kv, `source` MUST be a KEY name (NOT a column).
192
+ # The collector finds the row where (row[kv_mode.name_col] == `source`)
193
+ # and uses row[kv_mode.value_col].
194
+ - name: <dim_id>
195
+ source: <key_name> # REQUIRED. Key name resolved via kv_mode.name_col.
196
+
197
+ # ---- STATUS DIM (one-hot 1/0) ----
198
+ # Works in BOTH modes. Evaluates `status_when` against the resolved value:
199
+ # * columns mode: the value in the specified column for the row
200
+ # * kv mode: the value for the resolved key (row[kv_mode.value_col])
201
+ - name: <dim_id>
202
+ source: <column_name_or_key_name> # Same interpretation as above, per mode.
203
+ status_when: # Exactly ONE of the following:
204
+ equals: <string|number|bool> # Active (1) if value == this literal.
205
+ # in: [ <v1>, <v2>, ... ] # Active if value is in the list.
206
+ # match: '^regex$' # Active if value matches this regex.
207
+ ```
208
+ folding:
209
+ title: Config options
210
+ enabled: true
211
+ list:
212
+ - name: update_every
213
+ description: Data collection interval (seconds).
214
+ default_value: 1
215
+ required: false
216
+ group: Collection
217
+ - name: autodetection_retry
218
+ description: Autodetection retry interval (seconds). Not used for this collector. Set 0 to disable.
219
+ default_value: 0
220
+ required: false
221
+ group: Collection
222
+
223
+ - name: driver
224
+ description: >
225
+ SQL driver to use. Supported values: `mysql`, `pgx`, `oracle`, `sqlserver`.
226
+ default_value: mysql
227
+ required: true
228
+ group: Target
229
+ - name: dsn
230
+ description: >
231
+ Database connection string (DSN). The format depends on the selected driver (
232
+ ([MySQL](https://github.com/go-sql-driver/mysql#dsn-data-source-name),
233
+ [PostgreSQL](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS),
234
+ [MS SQL Server](https://github.com/denisenkom/go-mssqldb#connection-parameters-and-dsn)).
235
+ default_value: ""
236
+ required: true
237
+ group: Target
238
+
239
+ - name: timeout
240
+ description: Query and connection check timeout (seconds).
241
+ default_value: 5
242
+ required: false
243
+ group: Connection
244
+
245
+ - name: static_labels
246
+ description: >
247
+ A map of static labels added to every chart created by this job.
248
+ Useful for tagging charts with environment, region, or role.
249
+ default_value: "{}"
250
+ required: false
251
+ group: Labels
252
+
253
+ - name: queries
254
+ description: >
255
+ A list of reusable queries. Metric blocks can reference these via `query_ref` to avoid repeating SQL. See [Configuration Structure](#configuration) for details.
256
+ default_value: "[]"
257
+ required: false
258
+ group: Queries & Metrics
259
+ - name: metrics
260
+ description: >
261
+ A list of metric blocks. Each block defines how a query is executed and how its result is transformed into one or more charts. See [Configuration Structure](#configuration) for details.
262
+ default_value: "[]"
263
+ required: true
264
+ group: Queries & Metrics
265
+
266
+ - name: vnode
267
+ description: Associates this data collection job with a Virtual Node.
268
+ default_value: ""
269
+ required: false
270
+ group: Virtual Node
271
+ examples:
272
+ folding:
273
+ title: Config
274
+ enabled: true
275
+ list:
276
+ - name: Columns mode – per-database conflicts (with labels)
277
+ description: |
278
+ PostgreSQL example that collects database-level conflict counters from
279
+ `pg_stat_database_conflicts` and creates a separate chart instance per
280
+ database using `labels_from_row`.
281
+
282
+ The query:
283
+
284
+ ```sql
285
+ SELECT
286
+ datname,
287
+ confl_tablespace,
288
+ confl_lock,
289
+ confl_snapshot,
290
+ confl_bufferpin,
291
+ confl_deadlock
292
+ FROM pg_stat_database_conflicts;
293
+ ```
294
+
295
+ Example output:
296
+
297
+ | datname | confl_tablespace | confl_lock | confl_snapshot | confl_bufferpin | confl_deadlock |
298
+ |------------|------------------|------------|----------------|-----------------|----------------|
299
+ | postgres | 0 | 0 | 0 | 0 | 0 |
300
+ | production | 0 | 0 | 0 | 0 | 0 |
301
+
302
+ This configuration turns each row into a **chart instance** (one for
303
+ `db=postgres`, one for `db=production`) with five dimensions
304
+ (`confl_tablespace`, `confl_lock`, `confl_snapshot`, `confl_bufferpin`,
305
+ `confl_deadlock`).
306
+ config: |
307
+ jobs:
308
+ - name: pg_conflicts_per_db
309
+ driver: pgx
310
+ dsn: 'postgresql://netdata:password@127.0.0.1:5432/postgres'
311
+ timeout: 5
312
+
313
+ metrics:
314
+ - id: conflicts
315
+ mode: columns
316
+ query: |
317
+ SELECT
318
+ datname,
319
+ confl_tablespace,
320
+ confl_lock,
321
+ confl_snapshot,
322
+ confl_bufferpin,
323
+ confl_deadlock
324
+ FROM pg_stat_database_conflicts;
325
+ labels_from_row:
326
+ - source: datname
327
+ name: db
328
+ charts:
329
+ - title: "PostgreSQL conflicts"
330
+ context: sql.pg_conflicts
331
+ family: conflicts
332
+ units: conflicts
333
+ type: line
334
+ algorithm: absolute
335
+ dims:
336
+ - name: confl_tablespace
337
+ source: confl_tablespace
338
+ - name: confl_lock
339
+ source: confl_lock
340
+ - name: confl_snapshot
341
+ source: confl_snapshot
342
+ - name: confl_bufferpin
343
+ source: confl_bufferpin
344
+ - name: confl_deadlock
345
+ source: confl_deadlock
346
+
347
+ - name: Columns mode – single numeric value (uptime)
348
+ description: |
349
+ PostgreSQL example that exposes a single numeric metric (server uptime in
350
+ seconds) as a one-dimension chart using columns mode.
351
+
352
+ The query:
353
+
354
+ ```sql
355
+ SELECT
356
+ EXTRACT(
357
+ EPOCH FROM (now() - pg_postmaster_start_time())
358
+ ) AS uptime_seconds;
359
+ ```
360
+
361
+ Example output:
362
+
363
+ | uptime_seconds |
364
+ |----------------|
365
+ | 50.867359 |
366
+
367
+ This configuration maps the `uptime_seconds` column to a single
368
+ `uptime` dimension on the `sql.pg_uptime` chart.
369
+ config: |
370
+ jobs:
371
+ - name: pg_uptime
372
+ driver: pgx
373
+ dsn: 'postgresql://netdata:password@127.0.0.1:5432/postgres'
374
+ timeout: 5
375
+
376
+ metrics:
377
+ - id: uptime
378
+ mode: columns
379
+ query: |
380
+ SELECT
381
+ EXTRACT(
382
+ EPOCH FROM (now() - pg_postmaster_start_time())
383
+ ) AS uptime_seconds;
384
+ charts:
385
+ - title: "PostgreSQL uptime"
386
+ context: sql.pg_uptime
387
+ family: uptime
388
+ units: seconds
389
+ type: line
390
+ algorithm: absolute
391
+ dims:
392
+ - name: uptime
393
+ source: uptime_seconds
394
+
395
+ - name: KV mode – connection states as key/value pairs
396
+ description: |
397
+ PostgreSQL example that aggregates connection states from
398
+ `pg_stat_activity` and uses kv mode to map each state to a dimension.
399
+
400
+ The query:
401
+
402
+ ```sql
403
+ SELECT
404
+ state,
405
+ count(*) AS cnt
406
+ FROM pg_stat_activity
407
+ GROUP BY state;
408
+ ```
409
+
410
+ Example output:
411
+
412
+ | state | cnt |
413
+ |------------------------------|-----|
414
+ | active | 1 |
415
+ | idle | 14 |
416
+ | idle in transaction | 7 |
417
+ | idle in transaction (aborted)| 1 |
418
+ | fastpath function call | 1 |
419
+ | disabled | 1 |
420
+
421
+ With `mode: kv`, `state` becomes the **key** and `cnt` the **value**.
422
+ Each distinct `state` value is mapped to a chart dimension via `dims[*].source`.
423
+ config: |
424
+ jobs:
425
+ - name: pg_activity_states
426
+ driver: pgx
427
+ dsn: 'postgresql://netdata:password@127.0.0.1:5432/postgres'
428
+ timeout: 5
429
+
430
+ metrics:
431
+ - id: activity_states
432
+ mode: kv
433
+ query: |
434
+ SELECT
435
+ state,
436
+ count(*) AS cnt
437
+ FROM pg_stat_activity
438
+ GROUP BY state;
439
+ kv_mode:
440
+ name_col: state
441
+ value_col: cnt
442
+ charts:
443
+ - title: "PostgreSQL connection states"
444
+ context: sql.pg_activity_states
445
+ family: connections
446
+ units: connections
447
+ type: stacked
448
+ algorithm: absolute
449
+ dims:
450
+ - name: active
451
+ source: active
452
+ - name: idle
453
+ source: idle
454
+ - name: idle_in_transaction
455
+ source: "idle in transaction"
456
+ - name: idle_in_transaction_aborted
457
+ source: "idle in transaction (aborted)"
458
+ - name: fastpath_function_call
459
+ source: "fastpath function call"
460
+ - name: disabled
461
+ source: disabled
462
+
463
+ - name: Columns mode – map state values to a status metric
464
+ description: |
465
+ Simple PostgreSQL example that turns a boolean-like state into a 0/1
466
+ status metric using `status_when`.
467
+
468
+ The query:
469
+
470
+ ```sql
471
+ SELECT pg_is_in_recovery();
472
+ ```
473
+
474
+ Example output:
475
+
476
+ | pg_is_in_recovery |
477
+ |-------------------|
478
+ | f |
479
+
480
+ This configuration creates a single chart with two status dimensions:
481
+ - `in_recovery` becomes **1 when the value is `"t"`** and **0 otherwise**.
482
+ - `not_in_recovery` becomes **1 when the value is `"f"`** and **0 otherwise**.
483
+ config: |
484
+ jobs:
485
+ - name: pg_recovery_status
486
+ driver: pgx
487
+ dsn: 'postgresql://netdata:password@127.0.0.1:5432/postgres'
488
+ timeout: 5
489
+
490
+ metrics:
491
+ - id: recovery_status
492
+ mode: columns
493
+ query: |
494
+ SELECT pg_is_in_recovery();
495
+ charts:
496
+ - title: "PostgreSQL recovery status"
497
+ context: sql.pg_recovery_status
498
+ family: state
499
+ units: status
500
+ type: line
501
+ algorithm: absolute
502
+ dims:
503
+ - name: in_recovery
504
+ source: pg_is_in_recovery
505
+ status_when:
506
+ equals: "t"
507
+ - name: not_in_recovery
508
+ source: pg_is_in_recovery
509
+ status_when:
510
+ equals: "f"
511
+ troubleshooting:
512
+ problems:
513
+ list: []
514
+ alerts: []
515
+ metrics:
516
+ folding:
517
+ title: Metrics
518
+ enabled: false
519
+ description: |
520
+ Metrics and charts are **defined by your SQL queries and metric blocks** at runtime. They differ by database engine, schema, and configuration, and may include, for example, connection counts, cache hit ratios, row throughput, lock statistics, or custom business KPIs. Use the **Metrics** tab on the job’s dashboard to see exactly what is collected for that job.
521
+
522
+ :::tip
523
+
524
+ To change what is collected, edit the `metrics` (and optionally `queries`) sections in `go.d/sql.conf` for the corresponding job. Each change is reflected in Netdata charts after the next data collection.
525
+
526
+ :::
527
+
528
+ availability: []
529
+ scopes: []
src/go/plugin/go.d/collector/sql/query.go
new
+117
@@ -0,0 +1,117 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package sql
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "maps"
9
+ "time"
10
+)
11
+
12
+// QueryRowsCache maps query_id -> slice of row maps (col -> string value)
13
+type QueryRowsCache map[string][]map[string]string
14
+
15
+// execReusableQueries runs all queries declared in Config.Queries (new schema)
16
+// and returns their full rowsets in memory. It also returns per-query durations (ms).
17
+func (c *Collector) execReusableQueries(ctx context.Context) (QueryRowsCache, map[string]int64, error) {
18
+ cache := make(QueryRowsCache, len(c.Queries))
19
+ durations := make(map[string]int64, len(c.Queries))
20
+
21
+ for i, q := range c.Queries {
22
+ if q.ID == "" {
23
+ return nil, nil, fmt.Errorf("queries[%d] missing id", i+1)
24
+ }
25
+ if q.Query == "" {
26
+ return nil, nil, fmt.Errorf("queries[%d] missing query", i+1)
27
+ }
28
+
29
+ rows, dur, err := c.runSQL(ctx, q.Query)
30
+ if err != nil {
31
+ return nil, nil, fmt.Errorf("query %q failed: %w", q.ID, err)
32
+ }
33
+ cache[q.ID] = rows
34
+ durations[q.ID] = dur
35
+ }
36
+
37
+ return cache, durations, nil
38
+}
39
+
40
+// execMetricQueries resolves and executes the query for each metric block.
41
+// If a metric uses query_ref, it reuses rows from qcache (no re-query).
42
+// If a metric has inline query, it executes it and stores the rows.
43
+func (c *Collector) execMetricQueries(ctx context.Context, qcache QueryRowsCache) (QueryRowsCache, map[string]int64, error) {
44
+ cache := make(QueryRowsCache, len(c.Metrics))
45
+ durations := make(map[string]int64, len(c.Metrics))
46
+
47
+ for i, m := range c.Metrics {
48
+ if m.ID == "" {
49
+ return nil, nil, fmt.Errorf("metrics[%d] missing id", i+1)
50
+ }
51
+
52
+ switch {
53
+ case m.QueryRef != "":
54
+ // reuse pre-fetched rows; duration is 0 because we didn't re-run
55
+ rows, ok := qcache[m.QueryRef]
56
+ if !ok {
57
+ return nil, nil, fmt.Errorf("metrics[%d] query_ref %q not found in queries cache", i+1, m.QueryRef)
58
+ }
59
+ cache[m.ID] = rows
60
+ durations[m.ID] = 0
61
+ case m.Query != "":
62
+ rows, dur, err := c.runSQL(ctx, m.Query)
63
+ if err != nil {
64
+ return nil, nil, fmt.Errorf("metrics[%d] (%q) query failed: %w", i+1, m.ID, err)
65
+ }
66
+ cache[m.ID] = rows
67
+ durations[m.ID] = dur
68
+ default:
69
+ return nil, nil, fmt.Errorf("metrics[%d] must set one of query_ref or query", i+1)
70
+ }
71
+ }
72
+
73
+ return cache, durations, nil
74
+}
75
+
76
+// runSQL executes a SQL statement with c.Timeout and returns the rowset as []map[col]value.
77
+// Duration is milliseconds from QueryContext() start to first successful return.
78
+func (c *Collector) runSQL(ctx context.Context, query string) ([]map[string]string, int64, error) {
79
+ qctx := ctx
80
+ cancel := func() {}
81
+ if d := c.Timeout.Duration(); d > 0 {
82
+ qctx, cancel = context.WithTimeout(ctx, d)
83
+ }
84
+ defer cancel()
85
+
86
+ start := time.Now()
87
+ rows, err := c.db.QueryContext(qctx, query)
88
+ if err != nil {
89
+ return nil, 0, err
90
+ }
91
+ defer func() { _ = rows.Close() }()
92
+
93
+ duration := time.Since(start).Milliseconds()
94
+
95
+ columns, err := rows.Columns()
96
+ if err != nil {
97
+ return nil, duration, err
98
+ }
99
+
100
+ scan := makeRawBytesSlice(len(columns))
101
+ out := make([]map[string]string, 0, 64)
102
+ row := make(map[string]string, len(columns))
103
+
104
+ for rows.Next() {
105
+ if err := rows.Scan(scan...); err != nil {
106
+ return nil, duration, err
107
+ }
108
+ clear(row)
109
+ for i := range columns {
110
+ row[columns[i]] = rawBytesToString(scan[i])
111
+ }
112
+
113
+ out = append(out, maps.Clone(row))
114
+ }
115
+
116
+ return out, duration, rows.Err()
117
+}
src/go/plugin/go.d/collector/sql/testdata/config.json
new
+58
@@ -0,0 +1,58 @@
1
+{
2
+ "update_every": 123,
3
+ "autodetection_retry": 123,
4
+ "driver": "postgres",
5
+ "dsn": "host=localhost port=5432 user=netdata dbname=metrics sslmode=disable",
6
+ "timeout": 5,
7
+ "static_labels": {
8
+ "env": "prod",
9
+ "region": "eu-west"
10
+ },
11
+ "queries": [
12
+ {
13
+ "id": "get_users",
14
+ "query": "SELECT id, name FROM users"
15
+ }
16
+ ],
17
+ "metrics": [
18
+ {
19
+ "id": "users_metrics",
20
+ "query_ref": "get_users",
21
+ "query": "query",
22
+ "mode": "columns",
23
+ "kv_mode": {
24
+ "name_col": "metric_name",
25
+ "value_col": "metric_value"
26
+ },
27
+ "labels_from_row": [
28
+ {
29
+ "source": "status",
30
+ "name": "state"
31
+ }
32
+ ],
33
+ "charts": [
34
+ {
35
+ "title": "User Count",
36
+ "context": "app.users",
37
+ "family": "users",
38
+ "type": "line",
39
+ "units": "count",
40
+ "algorithm": "absolute",
41
+ "dims": [
42
+ {
43
+ "name": "users_total",
44
+ "source": "id",
45
+ "status_when": {
46
+ "equals": "equals",
47
+ "in": [
48
+ "in"
49
+ ],
50
+ "match": "match"
51
+ }
52
+ }
53
+ ]
54
+ }
55
+ ]
56
+ }
57
+ ]
58
+}
src/go/plugin/go.d/collector/sql/testdata/config.yaml
new
+40
@@ -0,0 +1,40 @@
1
+update_every: 123
2
+autodetection_retry: 123
3
+
4
+driver: "postgres"
5
+dsn: "host=localhost port=5432 user=netdata dbname=metrics sslmode=disable"
6
+timeout: 5
7
+
8
+static_labels:
9
+ env: "prod"
10
+ region: "eu-west"
11
+
12
+queries:
13
+ - id: "get_users"
14
+ query: "SELECT id, name FROM users"
15
+
16
+metrics:
17
+ - id: "users_metrics"
18
+ query_ref: "get_users"
19
+ query: "query"
20
+ mode: "columns"
21
+ kv_mode:
22
+ name_col: "metric_name"
23
+ value_col: "metric_value"
24
+ labels_from_row:
25
+ - source: "status"
26
+ name: "state"
27
+ charts:
28
+ - title: "User Count"
29
+ context: "app.users"
30
+ family: "users"
31
+ type: "line"
32
+ units: "count"
33
+ algorithm: "absolute"
34
+ dims:
35
+ - name: "users_total"
36
+ source: "id"
37
+ status_when:
38
+ equals: "equals"
39
+ in: ["in"]
40
+ match: "match"