master
go 363 lines 8.16 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package sql
4
5 import (
6 "context"
7 "database/sql"
8 "errors"
9 "fmt"
10 "slices"
11 "strconv"
12 "strings"
13 "time"
14
15 "github.com/jackc/pgx/v5"
16 "github.com/jackc/pgx/v5/stdlib"
17 "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth/sqladapter"
18 "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/oldmetrix"
19 )
20
21 func (c *Collector) collect(ctx context.Context) (map[string]int64, error) {
22 if c.db == nil {
23 if err := c.openConnection(ctx); err != nil {
24 return nil, err
25 }
26 }
27
28 qcache, qdur, err := c.execReusableQueries(ctx)
29 if err != nil {
30 return nil, err
31 }
32
33 mcache, mdur, err := c.execMetricQueries(ctx, qcache)
34 if err != nil {
35 return nil, err
36 }
37
38 mx := make(map[string]int64)
39
40 if err := c.collectMetrics(mx, mcache); err != nil {
41 return nil, err
42 }
43 c.collectQueryTimingMetrics(mx, qdur, mdur)
44
45 return mx, nil
46 }
47
48 func (c *Collector) collectMetrics(mx map[string]int64, mcache queryRowsCache) error {
49 for i, m := range c.Metrics {
50 rows, ok := mcache[m.ID]
51 if !ok {
52 continue
53 }
54
55 switch strings.ToLower(m.Mode) {
56 case "columns", "":
57 if err := c.collectMetricsModeColumns(mx, m, rows); err != nil {
58 return fmt.Errorf("metric %q (index %d) columns: %w", m.ID, i, err)
59 }
60 case "kv":
61 if err := c.collectMetricsModeKV(mx, m, rows); err != nil {
62 return fmt.Errorf("metric %q (index %d) kv: %w", m.ID, i, err)
63 }
64 default:
65 return fmt.Errorf("metric %q (index %d) unknown mode %q", m.ID, i, m.Mode)
66 }
67 }
68 return nil
69 }
70
71 func (c *Collector) collectMetricsModeColumns(mx map[string]int64, m ConfigMetricBlock, rows []map[string]string) error {
72 for _, ch := range m.Charts {
73 for _, row := range rows {
74 chartID := c.buildMetricChartID(m, ch, row)
75 if chartID == "" {
76 continue
77 }
78 c.createMetricBlockChart(chartID, m, ch, row)
79
80 for _, d := range ch.Dims {
81 raw, ok := row[d.Source]
82 if !ok {
83 continue
84 }
85 id := buildDimID(chartID, d.Name)
86
87 if d.StatusWhen == nil {
88 mx[id] += toInt64(raw)
89 } else if v, ok := mx[id]; !ok || v == 0 {
90 mx[id] = oldmetrix.Bool(c.evalStatusWhen(d.StatusWhen, raw))
91 }
92 }
93 }
94 }
95
96 return nil
97 }
98
99 func (c *Collector) collectMetricsModeKV(mx map[string]int64, m ConfigMetricBlock, rows []map[string]string) error {
100 if m.KVMode == nil {
101 return nil
102 }
103
104 nameCol := m.KVMode.NameCol
105 valCol := m.KVMode.ValueCol
106
107 for _, ch := range m.Charts {
108 for _, row := range rows {
109 chartID := c.buildMetricChartID(m, ch, row)
110 if chartID == "" {
111 continue
112 }
113 c.createMetricBlockChart(chartID, m, ch, row)
114
115 k, ok1 := row[nameCol]
116 vraw, ok2 := row[valCol]
117 if !ok1 || !ok2 {
118 continue
119 }
120
121 for _, d := range ch.Dims {
122 if d.Source != k {
123 continue
124 }
125 id := buildDimID(chartID, d.Name)
126
127 if d.StatusWhen == nil {
128 mx[id] += toInt64(vraw)
129 } else if v, ok := mx[id]; !ok || v == 0 {
130 mx[id] = oldmetrix.Bool(c.evalStatusWhen(d.StatusWhen, vraw))
131 }
132 }
133 }
134 }
135
136 return nil
137 }
138
139 func (c *Collector) collectQueryTimingMetrics(mx map[string]int64, qdur, mdur map[string]int64) {
140 collect := func(durations map[string]int64) {
141 // Reusable queries: label is <query_id>
142 // Metric-block inline queries: label is <metric_block_id>
143 for qid, dur := range durations {
144 chartID := c.buildTimingChartIDFromQueryID(qid)
145 c.createQueryTimingChart(chartID, qid)
146
147 dimID := buildDimID(chartID, "duration")
148 mx[dimID] = dur
149 }
150 }
151 collect(qdur)
152 collect(mdur)
153 }
154
155 func (c *Collector) evalStatusWhen(sw *ConfigStatusWhen, value string) bool {
156 switch {
157 case sw.Equals != "":
158 return value == sw.Equals
159 case len(sw.In) > 0:
160 return slices.Contains(sw.In, value)
161 case sw.re != nil:
162 return sw.re.MatchString(value)
163 default:
164 return false
165 }
166 }
167
168 func (c *Collector) openConnection(ctx context.Context) error {
169 if c.CloudAuth.IsEnabled() && c.Driver == "pgx" {
170 return c.openPostgresAzureADConnection(ctx)
171 }
172
173 driverName, dsn, err := c.resolveConnectionParams()
174 if err != nil {
175 return err
176 }
177
178 db, err := sql.Open(driverName, dsn)
179 if err != nil {
180 return fmt.Errorf("open %s: %w (dsn=%s)", driverName, err, redactDSN(dsn))
181 }
182
183 db.SetConnMaxLifetime(10 * time.Minute)
184
185 pingCtx := ctx
186 cancel := func() {}
187 if d := c.Timeout.Duration(); d > 0 {
188 pingCtx, cancel = context.WithTimeout(ctx, d)
189 }
190 defer cancel()
191
192 if err := db.PingContext(pingCtx); err != nil {
193 _ = db.Close()
194 return fmt.Errorf("ping %s: %w (dsn=%s)", driverName, err, redactDSN(dsn))
195 }
196
197 c.db = db
198 return nil
199 }
200
201 func (c *Collector) resolveConnectionParams() (string, string, error) {
202 driverName := c.Driver
203 dsn := c.DSN
204
205 if c.CloudAuth.IsEnabled() {
206 switch c.Driver {
207 case "sqlserver", "azuresql":
208 var err error
209 dsn, err = sqladapter.BuildMSSQLAzureADDSN(c.DSN, c.CloudAuth)
210 if err != nil {
211 return "", "", fmt.Errorf("prepare cloud_auth SQL Server DSN: %w", err)
212 }
213 driverName = sqladapter.MSSQLAzureDriverName
214 }
215 }
216
217 return driverName, dsn, nil
218 }
219
220 func (c *Collector) openPostgresAzureADConnection(ctx context.Context) error {
221 if c.azureTokenProvider == nil {
222 return errors.New("cloud auth token provider is not initialized for pgx")
223 }
224
225 cfg, err := pgx.ParseConfig(c.DSN)
226 if err != nil {
227 return fmt.Errorf("parse pgx DSN: %w", err)
228 }
229
230 db := stdlib.OpenDB(*cfg, stdlib.OptionBeforeConnect(c.azureADBeforeConnect))
231 db.SetConnMaxLifetime(10 * time.Minute)
232
233 pingCtx := ctx
234 cancel := func() {}
235 if d := c.Timeout.Duration(); d > 0 {
236 pingCtx, cancel = context.WithTimeout(ctx, d)
237 }
238 defer cancel()
239
240 if err := db.PingContext(pingCtx); err != nil {
241 _ = db.Close()
242 return fmt.Errorf("ping pgx: %w (dsn=%s)", err, redactDSN(c.DSN))
243 }
244
245 c.db = db
246 return nil
247 }
248
249 func (c *Collector) azureADBeforeConnect(ctx context.Context, cfg *pgx.ConnConfig) error {
250 token, _, err := c.azureTokenProvider.Token(ctx)
251 if err != nil {
252 return err
253 }
254 cfg.Password = token
255 return nil
256 }
257
258 func (c *Collector) buildMetricChartID(m ConfigMetricBlock, ch ConfigChartConfig, row map[string]string) string {
259 var b strings.Builder
260 b.Grow(128)
261
262 b.WriteString(c.Driver + "_" + m.ID + "_" + ch.Context)
263
264 for _, lf := range m.LabelsFromRow {
265 v, ok := row[lf.Source]
266 if !ok {
267 return ""
268 }
269 b.WriteString("_" + v)
270 }
271
272 return normalizeID(b.String())
273 }
274
275 func (c *Collector) buildTimingChartIDFromQueryID(queryID string) string {
276 // “reusable” query id or metric block id;
277 raw := fmt.Sprintf("%s_query_time_%s", c.Driver, queryID)
278 return normalizeID(raw)
279 }
280
281 func buildDimID(chartID, dimName string) string {
282 return normalizeID(chartID + "." + dimName)
283 }
284
285 var idReplacer = strings.NewReplacer(" ", "_", ".", "_")
286
287 func normalizeID(id string) string {
288 return strings.ToLower(idReplacer.Replace(id))
289 }
290
291 func redactDSN(dsn string) string {
292 if dsn == "" {
293 return dsn
294 }
295
296 // Find where authority starts (right after "://", if present)
297 authStart := 0
298 if i := strings.Index(dsn, "://"); i != -1 {
299 authStart = i + 3
300 }
301
302 // Find the *last* '@' after authority start
303 rel := strings.LastIndex(dsn[authStart:], "@")
304 if rel == -1 {
305 // no userinfo
306 return dsn
307 }
308 at := authStart + rel
309
310 // userinfo is between authority start and '@'
311 userinfo := dsn[authStart:at]
312 if userinfo == "" {
313 // malformed/empty userinfo; leave unchanged
314 return dsn
315 }
316
317 // If there's a colon, treat text before first ':' as user and the rest as password.
318 if before, _, ok := strings.Cut(userinfo, ":"); ok {
319 user := before
320 // Keep user, redact password
321 redacted := user + ":****"
322 return dsn[:authStart] + redacted + dsn[at:]
323 }
324
325 // No password present -> redact entire username
326 return dsn[:authStart] + "****" + dsn[at:]
327 }
328
329 func makeRawBytesSlice(size int) []any {
330 values := make([]any, size)
331 for i := range values {
332 var b sql.RawBytes
333 values[i] = &b
334 }
335 return values
336 }
337
338 func rawBytesToString(value any) string {
339 if rb, ok := value.(*sql.RawBytes); ok && rb != nil {
340 return string(*rb)
341 }
342 return ""
343 }
344
345 // local parser: int -> float -> bool -> 0
346 func toInt64(s string) int64 {
347 if s == "" {
348 return 0
349 }
350 if i, err := strconv.ParseInt(s, 10, 64); err == nil {
351 return i
352 }
353 if f, err := strconv.ParseFloat(s, 64); err == nil {
354 return int64(f)
355 }
356 switch strings.ToLower(strings.TrimSpace(s)) {
357 case "true", "t", "yes", "y", "on", "up":
358 return 1
359 case "false", "f", "no", "n", "off", "down":
360 return 0
361 }
362 return 0
363 }