fix(go.d.plugin): mysql error-info function improvements (#21650)
Ilya Mashchenko committed
Jan 27, 2026 at 20:40 UTC
f270a87070e29b7606e928c45e1039de6b4cea88
3 files changed
+514
-463
src/go/plugin/go.d/collector/mysql/func_deadlock_info.go
renamed
+265
-278
@@ -20,22 +20,6 @@ import (
20
"github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/strmutil"
21
)
22
23
-const (
24
- deadlockIdxRowID = iota
25
- deadlockIdxDeadlockID
26
- deadlockIdxTimestamp
27
- deadlockIdxProcessID
28
- deadlockIdxSpid
29
- deadlockIdxEcid
30
- deadlockIdxIsVictim
31
- deadlockIdxQueryText
32
- deadlockIdxLockMode
33
- deadlockIdxLockStatus
34
- deadlockIdxWaitResource
35
- deadlockIdxDatabase
36
- deadlockColumnCount
37
-)
38
-
23
const deadlockInfoMethodID = "deadlock-info"
24
25
const (
@@ -64,6 +48,199 @@ const (
48
deadlockParseErrorStatus = 561
49
)
50
51
+// deadlockRowData holds computed values for a single deadlock row.
52
+type deadlockRowData struct {
53
+ rowID string
54
+ deadlockID string
55
+ timestamp string
56
+ processID string
57
+ spid any
58
+ isVictim string
59
+ queryText string
60
+ lockMode string
61
+ lockStatus string
62
+ waitResource string
63
+ database any
64
+}
65
+
66
+// deadlockColumn defines a column for the deadlock-info function.
67
+type deadlockColumn struct {
68
+ funcapi.ColumnMeta
69
+ Value func(*deadlockRowData) any
70
+}
71
+
72
+func deadlockColumnSet(cols []deadlockColumn) funcapi.ColumnSet[deadlockColumn] {
73
+ return funcapi.Columns(cols, func(c deadlockColumn) funcapi.ColumnMeta { return c.ColumnMeta })
74
+}
75
+
76
+var deadlockColumns = []deadlockColumn{
77
+ {
78
+ ColumnMeta: funcapi.ColumnMeta{
79
+ Name: "row_id",
80
+ Tooltip: "Row ID",
81
+ Type: funcapi.FieldTypeString,
82
+ Sort: funcapi.FieldSortAscending,
83
+ Sortable: true,
84
+ Summary: funcapi.FieldSummaryCount,
85
+ Filter: funcapi.FieldFilterMultiselect,
86
+ UniqueKey: true,
87
+ Visible: false,
88
+ },
89
+ Value: func(r *deadlockRowData) any { return r.rowID },
90
+ },
91
+ {
92
+ ColumnMeta: funcapi.ColumnMeta{
93
+ Name: "timestamp",
94
+ Tooltip: "Timestamp",
95
+ Type: funcapi.FieldTypeTimestamp,
96
+ Sort: funcapi.FieldSortDescending,
97
+ Sortable: true,
98
+ Summary: funcapi.FieldSummaryMax,
99
+ Filter: funcapi.FieldFilterRange,
100
+ Visible: true,
101
+ Transform: funcapi.FieldTransformDatetime,
102
+ },
103
+ Value: func(r *deadlockRowData) any { return r.timestamp },
104
+ },
105
+ {
106
+ ColumnMeta: funcapi.ColumnMeta{
107
+ Name: "is_victim",
108
+ Tooltip: "Victim",
109
+ Type: funcapi.FieldTypeString,
110
+ Visualization: funcapi.FieldVisualPill,
111
+ Sort: funcapi.FieldSortAscending,
112
+ Sortable: true,
113
+ Summary: funcapi.FieldSummaryCount,
114
+ Filter: funcapi.FieldFilterMultiselect,
115
+ Visible: true,
116
+ },
117
+ Value: func(r *deadlockRowData) any { return r.isVictim },
118
+ },
119
+ {
120
+ ColumnMeta: funcapi.ColumnMeta{
121
+ Name: "query_text",
122
+ Tooltip: "Query",
123
+ Type: funcapi.FieldTypeString,
124
+ Sort: funcapi.FieldSortAscending,
125
+ Sortable: false,
126
+ Sticky: true,
127
+ Summary: funcapi.FieldSummaryCount,
128
+ Filter: funcapi.FieldFilterMultiselect,
129
+ FullWidth: true,
130
+ Wrap: true,
131
+ Visible: true,
132
+ },
133
+ Value: func(r *deadlockRowData) any { return r.queryText },
134
+ },
135
+ {
136
+ ColumnMeta: funcapi.ColumnMeta{
137
+ Name: "database",
138
+ Tooltip: "Database",
139
+ Type: funcapi.FieldTypeString,
140
+ Sort: funcapi.FieldSortAscending,
141
+ Sortable: true,
142
+ Summary: funcapi.FieldSummaryCount,
143
+ Filter: funcapi.FieldFilterMultiselect,
144
+ Visible: true,
145
+ },
146
+ Value: func(r *deadlockRowData) any { return r.database },
147
+ },
148
+ {
149
+ ColumnMeta: funcapi.ColumnMeta{
150
+ Name: "lock_mode",
151
+ Tooltip: "Lock Mode",
152
+ Type: funcapi.FieldTypeString,
153
+ Sort: funcapi.FieldSortAscending,
154
+ Sortable: true,
155
+ Summary: funcapi.FieldSummaryCount,
156
+ Filter: funcapi.FieldFilterMultiselect,
157
+ Visible: true,
158
+ },
159
+ Value: func(r *deadlockRowData) any { return r.lockMode },
160
+ },
161
+ {
162
+ ColumnMeta: funcapi.ColumnMeta{
163
+ Name: "lock_status",
164
+ Tooltip: "Lock Status",
165
+ Type: funcapi.FieldTypeString,
166
+ Visualization: funcapi.FieldVisualPill,
167
+ Sort: funcapi.FieldSortAscending,
168
+ Sortable: true,
169
+ Summary: funcapi.FieldSummaryCount,
170
+ Filter: funcapi.FieldFilterMultiselect,
171
+ Visible: true,
172
+ },
173
+ Value: func(r *deadlockRowData) any { return r.lockStatus },
174
+ },
175
+ {
176
+ ColumnMeta: funcapi.ColumnMeta{
177
+ Name: "wait_resource",
178
+ Tooltip: "Wait Resource",
179
+ Type: funcapi.FieldTypeString,
180
+ Sort: funcapi.FieldSortAscending,
181
+ Sortable: false,
182
+ Summary: funcapi.FieldSummaryCount,
183
+ Filter: funcapi.FieldFilterMultiselect,
184
+ Visible: true,
185
+ },
186
+ Value: func(r *deadlockRowData) any { return r.waitResource },
187
+ },
188
+ {
189
+ ColumnMeta: funcapi.ColumnMeta{
190
+ Name: "spid",
191
+ Tooltip: "Connection ID",
192
+ Type: funcapi.FieldTypeInteger,
193
+ Sort: funcapi.FieldSortAscending,
194
+ Sortable: true,
195
+ Summary: funcapi.FieldSummaryCount,
196
+ Filter: funcapi.FieldFilterRange,
197
+ Visible: true,
198
+ Transform: funcapi.FieldTransformNumber,
199
+ },
200
+ Value: func(r *deadlockRowData) any { return r.spid },
201
+ },
202
+ {
203
+ ColumnMeta: funcapi.ColumnMeta{
204
+ Name: "process_id",
205
+ Tooltip: "Process ID",
206
+ Type: funcapi.FieldTypeString,
207
+ Sort: funcapi.FieldSortAscending,
208
+ Sortable: true,
209
+ Summary: funcapi.FieldSummaryCount,
210
+ Filter: funcapi.FieldFilterMultiselect,
211
+ Visible: true,
212
+ },
213
+ Value: func(r *deadlockRowData) any { return r.processID },
214
+ },
215
+ {
216
+ ColumnMeta: funcapi.ColumnMeta{
217
+ Name: "deadlock_id",
218
+ Tooltip: "Deadlock ID",
219
+ Type: funcapi.FieldTypeString,
220
+ Sort: funcapi.FieldSortAscending,
221
+ Sortable: true,
222
+ Summary: funcapi.FieldSummaryCount,
223
+ Filter: funcapi.FieldFilterMultiselect,
224
+ Visible: true,
225
+ },
226
+ Value: func(r *deadlockRowData) any { return r.deadlockID },
227
+ },
228
+ {
229
+ ColumnMeta: funcapi.ColumnMeta{
230
+ Name: "ecid",
231
+ Tooltip: "ECID",
232
+ Type: funcapi.FieldTypeInteger,
233
+ Sort: funcapi.FieldSortAscending,
234
+ Sortable: true,
235
+ Summary: funcapi.FieldSummaryCount,
236
+ Filter: funcapi.FieldFilterRange,
237
+ Visible: false, // SQL Server concept, not applicable to MySQL/MariaDB
238
+ Transform: funcapi.FieldTransformNumber,
239
+ },
240
+ Value: func(r *deadlockRowData) any { return nil },
241
+ },
242
+}
243
+
244
func deadlockInfoMethodConfig() funcapi.MethodConfig {
245
return funcapi.MethodConfig{
246
ID: deadlockInfoMethodID,
@@ -100,37 +277,13 @@ func (f *funcDeadlockInfo) Handle(ctx context.Context, method string, params fun
277
return funcapi.UnavailableResponse("collector is still initializing, please retry in a few seconds")
278
}
279
}
103
- return f.router.collector.collectDeadlockInfo(ctx)
280
+ return f.collectData(ctx)
281
}
282
283
func (f *funcDeadlockInfo) Cleanup(ctx context.Context) {}
284
108
-type mysqlDeadlockTxn struct {
109
- txnNum int
110
- threadID string
111
- queryText string
112
- lockMode string
113
- lockStatus string
114
- waitResource string
115
-}
116
-
117
-type mysqlDeadlockParseResult struct {
118
- found bool
119
- deadlockTime time.Time
120
- victimTxnNum int
121
- transactions []*mysqlDeadlockTxn
122
- parseErr error
123
-}
124
-
125
-func (c *Collector) deadlockInfoParams(context.Context) ([]funcapi.ParamConfig, error) {
126
- if !c.Config.GetDeadlockInfoFunctionEnabled() {
127
- return nil, fmt.Errorf("deadlock-info function disabled in configuration")
128
- }
129
- return []funcapi.ParamConfig{}, nil
130
-}
131
-
132
-func (c *Collector) collectDeadlockInfo(ctx context.Context) *funcapi.FunctionResponse {
133
- if !c.Config.GetDeadlockInfoFunctionEnabled() {
285
+func (f *funcDeadlockInfo) collectData(ctx context.Context) *funcapi.FunctionResponse {
286
+ if !f.router.collector.Config.GetDeadlockInfoFunctionEnabled() {
287
return &funcapi.FunctionResponse{
288
Status: 503,
289
Message: "deadlock-info function has been disabled in configuration. " +
@@ -138,61 +291,69 @@ func (c *Collector) collectDeadlockInfo(ctx context.Context) *funcapi.FunctionRe
291
}
292
}
293
141
- statusText, err := c.queryInnoDBStatus(ctx)
294
+ statusText, err := f.queryInnoDBStatus(ctx)
295
if err != nil {
296
if errors.Is(err, context.DeadlineExceeded) {
144
- return c.deadlockInfoResponse(504, "deadlock query timed out", nil)
297
+ return f.buildResponse(504, "deadlock query timed out", nil)
298
}
299
if isMySQLPermissionError(err) {
147
- return c.deadlockInfoResponse(
300
+ return f.buildResponse(
301
403,
302
"Deadlock info requires permission to run SHOW ENGINE INNODB STATUS. "+
303
"Grant with: GRANT USAGE, REPLICATION CLIENT, PROCESS ON *.* TO 'netdata'@'%';",
304
nil,
305
)
306
}
154
- c.Warningf("deadlock-info: query failed: %v", err)
155
- return c.deadlockInfoResponse(500, fmt.Sprintf("deadlock query failed: %v", err), nil)
307
+ f.router.collector.Warningf("deadlock-info: query failed: %v", err)
308
+ return f.buildResponse(500, fmt.Sprintf("deadlock query failed: %v", err), nil)
309
}
310
311
parseRes := parseInnoDBDeadlock(statusText, time.Now().UTC())
312
if parseRes.parseErr != nil {
160
- c.Warningf("deadlock-info: parse failed: %v", parseRes.parseErr)
161
- return c.deadlockInfoResponse(deadlockParseErrorStatus, "deadlock section could not be parsed", nil)
313
+ f.router.collector.Warningf("deadlock-info: parse failed: %v", parseRes.parseErr)
314
+ return f.buildResponse(deadlockParseErrorStatus, "deadlock section could not be parsed", nil)
315
}
316
if !parseRes.found {
164
- return c.deadlockInfoResponse(200, "no deadlock found in SHOW ENGINE INNODB STATUS", nil)
317
+ return f.buildResponse(200, "no deadlock found in SHOW ENGINE INNODB STATUS", nil)
318
}
319
320
deadlockID := generateDeadlockID(parseRes.deadlockTime)
321
rows := buildDeadlockRows(parseRes, deadlockID)
322
if len(rows) == 0 {
170
- return c.deadlockInfoResponse(200, "deadlock detected but no transactions could be parsed", nil)
323
+ return f.buildResponse(200, "deadlock detected but no transactions could be parsed", nil)
324
}
325
173
- return c.deadlockInfoResponse(200, "latest detected deadlock", rows)
326
+ return f.buildResponse(200, "latest detected deadlock", rows)
327
}
328
176
-func (c *Collector) deadlockInfoResponse(status int, message string, data [][]any) *funcapi.FunctionResponse {
177
- if data == nil {
178
- data = make([][]any, 0)
329
+func (f *funcDeadlockInfo) buildResponse(status int, message string, rowsData []deadlockRowData) *funcapi.FunctionResponse {
330
+ data := make([][]any, 0, len(rowsData))
331
+ for i := range rowsData {
332
+ row := make([]any, len(deadlockColumns))
333
+ for j, col := range deadlockColumns {
334
+ row[j] = col.Value(&rowsData[i])
335
+ }
336
+ data = append(data, row)
337
}
338
+
339
+ cs := deadlockColumnSet(deadlockColumns)
340
+
341
return &funcapi.FunctionResponse{
342
Status: status,
343
Help: deadlockInfoHelp,
344
Message: message,
184
- Columns: c.buildDeadlockColumns(),
345
+ Columns: cs.BuildColumns(),
346
Data: data,
347
DefaultSortColumn: "timestamp",
348
}
349
}
350
190
-func (c *Collector) queryInnoDBStatus(ctx context.Context) (string, error) {
191
- qctx, cancel := context.WithTimeout(ctx, c.Timeout.Duration())
351
+func (f *funcDeadlockInfo) queryInnoDBStatus(ctx context.Context) (string, error) {
352
+ qctx, cancel := context.WithTimeout(ctx, f.router.collector.Timeout.Duration())
353
defer cancel()
354
355
var typ, name, status sql.NullString
195
- if err := c.db.QueryRowContext(qctx, queryShowEngineInnoDBStatus).Scan(&typ, &name, &status); err != nil {
356
+ if err := f.router.collector.db.QueryRowContext(qctx, queryShowEngineInnoDBStatus).Scan(&typ, &name, &status); err != nil {
357
return "", err
358
}
359
if !status.Valid {
@@ -201,208 +362,21 @@ func (c *Collector) queryInnoDBStatus(ctx context.Context) (string, error) {
362
return status.String, nil
363
}
364
204
-func (c *Collector) buildDeadlockColumns() map[string]any {
205
- const (
206
- ftString = funcapi.FieldTypeString
207
- ftInteger = funcapi.FieldTypeInteger
208
- ftTimestamp = funcapi.FieldTypeTimestamp
209
-
210
- trNone = funcapi.FieldTransformNone
211
- trNumber = funcapi.FieldTransformNumber
212
- trDatetime = funcapi.FieldTransformDatetime
213
-
214
- visValue = funcapi.FieldVisualValue
215
- visPill = funcapi.FieldVisualPill
216
-
217
- sortAsc = funcapi.FieldSortAscending
218
- sortDesc = funcapi.FieldSortDescending
219
-
220
- summaryCount = funcapi.FieldSummaryCount
221
- summaryMax = funcapi.FieldSummaryMax
222
-
223
- filterMulti = funcapi.FieldFilterMultiselect
224
- filterRange = funcapi.FieldFilterRange
225
- )
365
+type mysqlDeadlockTxn struct {
366
+ txnNum int
367
+ threadID string
368
+ queryText string
369
+ lockMode string
370
+ lockStatus string
371
+ waitResource string
372
+}
373
227
- columns := map[string]any{
228
- "row_id": funcapi.Column{
229
- Index: deadlockIdxRowID,
230
- Name: "Row ID",
231
- Type: ftString,
232
- Visualization: visValue,
233
- Sort: sortAsc,
234
- Sortable: true,
235
- Sticky: false,
236
- Summary: summaryCount,
237
- Filter: filterMulti,
238
- FullWidth: false,
239
- Wrap: false,
240
- UniqueKey: true,
241
- Visible: false,
242
- ValueOptions: funcapi.ValueOptions{
243
- Transform: trNone,
244
- DecimalPoints: 0,
245
- },
246
- }.BuildColumn(),
247
- "deadlock_id": funcapi.Column{
248
- Index: deadlockIdxDeadlockID,
249
- Name: "Deadlock ID",
250
- Type: ftString,
251
- Visualization: visValue,
252
- Sort: sortAsc,
253
- Sortable: true,
254
- Summary: summaryCount,
255
- Filter: filterMulti,
256
- Visible: true,
257
- ValueOptions: funcapi.ValueOptions{
258
- Transform: trNone,
259
- },
260
- }.BuildColumn(),
261
- "timestamp": funcapi.Column{
262
- Index: deadlockIdxTimestamp,
263
- Name: "Timestamp",
264
- Type: ftTimestamp,
265
- Visualization: visValue,
266
- Sort: sortDesc,
267
- Sortable: true,
268
- Summary: summaryMax,
269
- Filter: filterRange,
270
- Visible: true,
271
- ValueOptions: funcapi.ValueOptions{
272
- Transform: trDatetime,
273
- },
274
- }.BuildColumn(),
275
- "process_id": funcapi.Column{
276
- Index: deadlockIdxProcessID,
277
- Name: "Process ID",
278
- Type: ftString,
279
- Visualization: visValue,
280
- Sort: sortAsc,
281
- Sortable: true,
282
- Summary: summaryCount,
283
- Filter: filterMulti,
284
- Visible: true,
285
- ValueOptions: funcapi.ValueOptions{
286
- Transform: trNone,
287
- },
288
- }.BuildColumn(),
289
- "spid": funcapi.Column{
290
- Index: deadlockIdxSpid,
291
- Name: "Connection ID",
292
- Type: ftInteger,
293
- Visualization: visValue,
294
- Sort: sortAsc,
295
- Sortable: true,
296
- Summary: summaryCount,
297
- Filter: filterRange,
298
- Visible: true,
299
- ValueOptions: funcapi.ValueOptions{
300
- Transform: trNumber,
301
- },
302
- }.BuildColumn(),
303
- "ecid": funcapi.Column{
304
- Index: deadlockIdxEcid,
305
- Name: "ECID",
306
- Type: ftInteger,
307
- Visualization: visValue,
308
- Sort: sortAsc,
309
- Sortable: true,
310
- Summary: summaryCount,
311
- Filter: filterRange,
312
- Visible: true,
313
- ValueOptions: funcapi.ValueOptions{
314
- Transform: trNumber,
315
- },
316
- }.BuildColumn(),
317
- "is_victim": funcapi.Column{
318
- Index: deadlockIdxIsVictim,
319
- Name: "Victim",
320
- Type: ftString,
321
- Visualization: visPill,
322
- Sort: sortAsc,
323
- Sortable: true,
324
- Summary: summaryCount,
325
- Filter: filterMulti,
326
- Visible: true,
327
- ValueOptions: funcapi.ValueOptions{
328
- Transform: trNone,
329
- },
330
- }.BuildColumn(),
331
- "query_text": funcapi.Column{
332
- Index: deadlockIdxQueryText,
333
- Name: "Query",
334
- Type: ftString,
335
- Visualization: visValue,
336
- Sort: sortAsc,
337
- Sortable: false,
338
- Sticky: true,
339
- Summary: summaryCount,
340
- Filter: filterMulti,
341
- FullWidth: true,
342
- Wrap: true,
343
- Visible: true,
344
- ValueOptions: funcapi.ValueOptions{
345
- Transform: trNone,
346
- },
347
- }.BuildColumn(),
348
- "lock_mode": funcapi.Column{
349
- Index: deadlockIdxLockMode,
350
- Name: "Lock Mode",
351
- Type: ftString,
352
- Visualization: visValue,
353
- Sort: sortAsc,
354
- Sortable: true,
355
- Summary: summaryCount,
356
- Filter: filterMulti,
357
- Visible: true,
358
- ValueOptions: funcapi.ValueOptions{
359
- Transform: trNone,
360
- },
361
- }.BuildColumn(),
362
- "lock_status": funcapi.Column{
363
- Index: deadlockIdxLockStatus,
364
- Name: "Lock Status",
365
- Type: ftString,
366
- Visualization: visPill,
367
- Sort: sortAsc,
368
- Sortable: true,
369
- Summary: summaryCount,
370
- Filter: filterMulti,
371
- Visible: true,
372
- ValueOptions: funcapi.ValueOptions{
373
- Transform: trNone,
374
- },
375
- }.BuildColumn(),
376
- "wait_resource": funcapi.Column{
377
- Index: deadlockIdxWaitResource,
378
- Name: "Wait Resource",
379
- Type: ftString,
380
- Visualization: visValue,
381
- Sort: sortAsc,
382
- Sortable: false,
383
- Summary: summaryCount,
384
- Filter: filterMulti,
385
- Visible: true,
386
- ValueOptions: funcapi.ValueOptions{
387
- Transform: trNone,
388
- },
389
- }.BuildColumn(),
390
- "database": funcapi.Column{
391
- Index: deadlockIdxDatabase,
392
- Name: "Database",
393
- Type: ftString,
394
- Visualization: visValue,
395
- Sort: sortAsc,
396
- Sortable: true,
397
- Summary: summaryCount,
398
- Filter: filterMulti,
399
- Visible: true,
400
- ValueOptions: funcapi.ValueOptions{
401
- Transform: trNone,
402
- },
403
- }.BuildColumn(),
404
- }
405
- return columns
374
+type mysqlDeadlockParseResult struct {
375
+ found bool
376
+ deadlockTime time.Time
377
+ victimTxnNum int
378
+ transactions []*mysqlDeadlockTxn
379
+ parseErr error
380
}
381
382
func parseInnoDBDeadlock(status string, now time.Time) mysqlDeadlockParseResult {
@@ -567,8 +541,8 @@ func parseInnoDBDeadlock(status string, now time.Time) mysqlDeadlockParseResult
541
return result
542
}
543
570
-func buildDeadlockRows(parseRes mysqlDeadlockParseResult, deadlockID string) [][]any {
571
- rows := make([][]any, 0, len(parseRes.transactions))
544
+func buildDeadlockRows(parseRes mysqlDeadlockParseResult, deadlockID string) []deadlockRowData {
545
+ rows := make([]deadlockRowData, 0, len(parseRes.transactions))
546
timestamp := parseRes.deadlockTime.UTC().Format(time.RFC3339Nano)
547
548
for _, txn := range parseRes.transactions {
@@ -594,7 +568,7 @@ func buildDeadlockRows(parseRes mysqlDeadlockParseResult, deadlockID string) [][
568
}
569
570
queryText := strmutil.TruncateText(strings.TrimSpace(txn.queryText), topQueriesMaxTextLength)
597
- lockMode := strings.TrimSpace(txn.lockMode)
571
+ lockMode := formatLockMode(strings.TrimSpace(txn.lockMode))
572
lockStatus := strings.TrimSpace(txn.lockStatus)
573
waitResource := strmutil.TruncateText(strings.TrimSpace(txn.waitResource), topQueriesMaxTextLength)
574
database := extractDeadlockDatabase(waitResource, queryText)
@@ -604,20 +578,19 @@ func buildDeadlockRows(parseRes mysqlDeadlockParseResult, deadlockID string) [][
578
databaseValue = database
579
}
580
607
- row := make([]any, deadlockColumnCount)
608
- row[deadlockIdxRowID] = fmt.Sprintf("%s:%s", deadlockID, processID)
609
- row[deadlockIdxDeadlockID] = deadlockID
610
- row[deadlockIdxTimestamp] = timestamp
611
- row[deadlockIdxProcessID] = processID
612
- row[deadlockIdxSpid] = spid
613
- row[deadlockIdxEcid] = nil
614
- row[deadlockIdxIsVictim] = isVictim
615
- row[deadlockIdxQueryText] = queryText
616
- row[deadlockIdxLockMode] = lockMode
617
- row[deadlockIdxLockStatus] = lockStatus
618
- row[deadlockIdxWaitResource] = waitResource
619
- row[deadlockIdxDatabase] = databaseValue
620
- rows = append(rows, row)
581
+ rows = append(rows, deadlockRowData{
582
+ rowID: fmt.Sprintf("%s:%s", deadlockID, processID),
583
+ deadlockID: deadlockID,
584
+ timestamp: timestamp,
585
+ processID: processID,
586
+ spid: spid,
587
+ isVictim: isVictim,
588
+ queryText: queryText,
589
+ lockMode: lockMode,
590
+ lockStatus: lockStatus,
591
+ waitResource: waitResource,
592
+ database: databaseValue,
593
+ })
594
}
595
596
return rows
@@ -777,3 +750,17 @@ func isMySQLPermissionError(err error) bool {
750
strings.Contains(msg, "permission denied") ||
751
strings.Contains(msg, "process privilege")
752
}
753
+
754
+// formatLockMode converts InnoDB lock mode abbreviations to human-readable format.
755
+func formatLockMode(mode string) string {
756
+ names := map[string]string{
757
+ "X": "Exclusive",
758
+ "S": "Shared",
759
+ "IX": "Intent Exclusive",
760
+ "IS": "Intent Shared",
761
+ }
762
+ if name, ok := names[mode]; ok {
763
+ return fmt.Sprintf("%s (%s)", name, mode)
764
+ }
765
+ return mode
766
+}
src/go/plugin/go.d/collector/mysql/func_deadlock_info_test.go
renamed
+26
-15
@@ -259,7 +259,13 @@ func TestParseInnoDBDeadlock_MalformedSection(t *testing.T) {
259
assert.Len(t, res.transactions, 0)
260
}
261
262
-func TestCollector_collectDeadlockInfo_ParseError(t *testing.T) {
262
+// newTestDeadlockHandler creates a funcDeadlockInfo handler for testing.
263
+func newTestDeadlockHandler(c *Collector) *funcDeadlockInfo {
264
+ router := &funcRouter{collector: c}
265
+ return newFuncDeadlockInfo(router)
266
+}
267
+
268
+func TestFuncDeadlockInfo_collectData_ParseError(t *testing.T) {
269
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
270
require.NoError(t, err)
271
defer func() { _ = db.Close() }()
@@ -270,15 +276,16 @@ func TestCollector_collectDeadlockInfo_ParseError(t *testing.T) {
276
277
collr := New()
278
collr.db = db
279
+ handler := newTestDeadlockHandler(collr)
280
274
- resp := collr.collectDeadlockInfo(context.Background())
281
+ resp := handler.collectData(context.Background())
282
require.NotNil(t, resp)
283
assert.Equal(t, deadlockParseErrorStatus, resp.Status)
284
assert.Contains(t, resp.Message, "could not be parsed")
285
assert.NoError(t, mock.ExpectationsWereMet())
286
}
287
281
-func TestCollector_collectDeadlockInfo_QueryError(t *testing.T) {
288
+func TestFuncDeadlockInfo_collectData_QueryError(t *testing.T) {
289
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
290
require.NoError(t, err)
291
defer func() { _ = db.Close() }()
@@ -288,15 +295,16 @@ func TestCollector_collectDeadlockInfo_QueryError(t *testing.T) {
295
296
collr := New()
297
collr.db = db
298
+ handler := newTestDeadlockHandler(collr)
299
292
- resp := collr.collectDeadlockInfo(context.Background())
300
+ resp := handler.collectData(context.Background())
301
require.NotNil(t, resp)
302
assert.Equal(t, 500, resp.Status)
303
assert.Contains(t, resp.Message, "deadlock query failed")
304
assert.NoError(t, mock.ExpectationsWereMet())
305
}
306
299
-func TestCollector_collectDeadlockInfo_Timeout(t *testing.T) {
307
+func TestFuncDeadlockInfo_collectData_Timeout(t *testing.T) {
308
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
309
require.NoError(t, err)
310
defer func() { _ = db.Close() }()
@@ -306,21 +314,23 @@ func TestCollector_collectDeadlockInfo_Timeout(t *testing.T) {
314
315
collr := New()
316
collr.db = db
317
+ handler := newTestDeadlockHandler(collr)
318
310
- resp := collr.collectDeadlockInfo(context.Background())
319
+ resp := handler.collectData(context.Background())
320
require.NotNil(t, resp)
321
assert.Equal(t, 504, resp.Status)
322
assert.Contains(t, resp.Message, "timed out")
323
assert.NoError(t, mock.ExpectationsWereMet())
324
}
325
317
-func TestCollector_collectDeadlockInfo_PermissionDenied(t *testing.T) {
326
+func TestFuncDeadlockInfo_collectData_PermissionDenied(t *testing.T) {
327
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
328
require.NoError(t, err)
329
defer func() { _ = db.Close() }()
330
331
collr := New()
332
collr.db = db
333
+ handler := newTestDeadlockHandler(collr)
334
335
permErr := &mysqlDriver.MySQLError{
336
Number: 1227,
@@ -328,18 +338,19 @@ func TestCollector_collectDeadlockInfo_PermissionDenied(t *testing.T) {
338
}
339
mock.ExpectQuery(queryShowEngineInnoDBStatus).WillReturnError(permErr)
340
331
- resp := collr.collectDeadlockInfo(context.Background())
341
+ resp := handler.collectData(context.Background())
342
require.NotNil(t, resp)
343
assert.Equal(t, 403, resp.Status)
344
assert.Contains(t, resp.Message, "PROCESS")
345
assert.NoError(t, mock.ExpectationsWereMet())
346
}
347
338
-func TestCollector_collectDeadlockInfo_Disabled(t *testing.T) {
348
+func TestFuncDeadlockInfo_collectData_Disabled(t *testing.T) {
349
c := New()
350
c.Config.DeadlockInfoFunctionEnabled = boolPtr(false)
351
+ handler := newTestDeadlockHandler(c)
352
342
- resp := c.collectDeadlockInfo(context.Background())
353
+ resp := handler.collectData(context.Background())
354
require.Equal(t, 503, resp.Status)
355
assert.Contains(t, resp.Message, "disabled")
356
}
@@ -351,14 +362,14 @@ func TestBuildDeadlockRows(t *testing.T) {
362
rows := buildDeadlockRows(res, deadlockID)
363
364
assert.Len(t, rows, 2)
354
- assert.Equal(t, deadlockID, rows[0][deadlockIdxDeadlockID])
355
- assert.Equal(t, deadlockID, rows[1][deadlockIdxDeadlockID])
356
- assert.Equal(t, deadlockID+":10", rows[0][deadlockIdxRowID])
357
- assert.Equal(t, deadlockID+":11", rows[1][deadlockIdxRowID])
365
+ assert.Equal(t, deadlockID, rows[0].deadlockID)
366
+ assert.Equal(t, deadlockID, rows[1].deadlockID)
367
+ assert.Equal(t, deadlockID+":10", rows[0].rowID)
368
+ assert.Equal(t, deadlockID+":11", rows[1].rowID)
369
370
hasDatabase := false
371
for _, row := range rows {
361
- if row[deadlockIdxDatabase] == "netdata" {
372
+ if row.database == "netdata" {
373
hasDatabase = true
374
break
375
}
src/go/plugin/go.d/collector/mysql/func_error_info.go
renamed
+223
-170
@@ -4,8 +4,11 @@ package mysql
4
5
import (
6
"context"
7
+ "crypto/md5"
8
"database/sql"
9
+ "encoding/hex"
10
"fmt"
11
+ "strconv"
12
"strings"
13
14
"github.com/netdata/netdata/go/plugins/pkg/funcapi"
@@ -20,6 +23,89 @@ const (
23
24
const errorInfoMethodID = "error-info"
25
26
+// errorInfoColumn defines a column for the error-info function.
27
+type errorInfoColumn struct {
28
+ funcapi.ColumnMeta
29
+ Value func(*mysqlErrorRow) any
30
+}
31
+
32
+func errorInfoColumnSet(cols []errorInfoColumn) funcapi.ColumnSet[errorInfoColumn] {
33
+ return funcapi.Columns(cols, func(c errorInfoColumn) funcapi.ColumnMeta { return c.ColumnMeta })
34
+}
35
+
36
+var errorInfoColumns = []errorInfoColumn{
37
+ {
38
+ ColumnMeta: funcapi.ColumnMeta{
39
+ Name: "digest",
40
+ Tooltip: "Digest",
41
+ Type: funcapi.FieldTypeString,
42
+ Sortable: true,
43
+ Visible: false,
44
+ UniqueKey: true,
45
+ },
46
+ Value: func(r *mysqlErrorRow) any { return r.Digest },
47
+ },
48
+ {
49
+ ColumnMeta: funcapi.ColumnMeta{
50
+ Name: "query",
51
+ Tooltip: "Query",
52
+ Type: funcapi.FieldTypeString,
53
+ Sortable: true,
54
+ Visible: true,
55
+ Sticky: true,
56
+ FullWidth: true,
57
+ },
58
+ Value: func(r *mysqlErrorRow) any { return r.Query },
59
+ },
60
+ {
61
+ ColumnMeta: funcapi.ColumnMeta{
62
+ Name: "schema",
63
+ Tooltip: "Schema",
64
+ Type: funcapi.FieldTypeString,
65
+ Sortable: true,
66
+ Visible: true,
67
+ },
68
+ Value: func(r *mysqlErrorRow) any { return r.Schema },
69
+ },
70
+ {
71
+ ColumnMeta: funcapi.ColumnMeta{
72
+ Name: "errorNumber",
73
+ Tooltip: "Error Number",
74
+ Type: funcapi.FieldTypeInteger,
75
+ Sortable: true,
76
+ Visible: true,
77
+ Transform: funcapi.FieldTransformNumber,
78
+ },
79
+ Value: func(r *mysqlErrorRow) any {
80
+ if r.ErrorNumber == nil {
81
+ return nil
82
+ }
83
+ return *r.ErrorNumber
84
+ },
85
+ },
86
+ {
87
+ ColumnMeta: funcapi.ColumnMeta{
88
+ Name: "sqlState",
89
+ Tooltip: "SQL State",
90
+ Type: funcapi.FieldTypeString,
91
+ Sortable: true,
92
+ Visible: true,
93
+ },
94
+ Value: func(r *mysqlErrorRow) any { return r.SQLState },
95
+ },
96
+ {
97
+ ColumnMeta: funcapi.ColumnMeta{
98
+ Name: "errorMessage",
99
+ Tooltip: "Error Message",
100
+ Type: funcapi.FieldTypeString,
101
+ Sortable: false,
102
+ Visible: true,
103
+ FullWidth: true,
104
+ },
105
+ Value: func(r *mysqlErrorRow) any { return r.Message },
106
+ },
107
+}
108
+
109
func errorInfoMethodConfig() funcapi.MethodConfig {
110
return funcapi.MethodConfig{
111
ID: errorInfoMethodID,
@@ -56,11 +142,109 @@ func (f *funcErrorInfo) Handle(ctx context.Context, method string, params funcap
142
return funcapi.UnavailableResponse("collector is still initializing, please retry in a few seconds")
143
}
144
}
59
- return f.router.collector.collectErrorInfo(ctx)
145
+ return f.collectData(ctx)
146
}
147
148
func (f *funcErrorInfo) Cleanup(ctx context.Context) {}
149
150
+func (f *funcErrorInfo) collectData(ctx context.Context) *funcapi.FunctionResponse {
151
+ if !f.router.collector.Config.GetErrorInfoFunctionEnabled() {
152
+ return &funcapi.FunctionResponse{
153
+ Status: 503,
154
+ Message: "error-info not enabled: function disabled in configuration. " +
155
+ "To enable, set error_info_function_enabled: true in the MySQL collector config.",
156
+ }
157
+ }
158
+
159
+ available, err := f.checkPerformanceSchema(ctx)
160
+ if err != nil {
161
+ return &funcapi.FunctionResponse{
162
+ Status: 500,
163
+ Message: fmt.Sprintf("failed to check performance_schema availability: %v", err),
164
+ }
165
+ }
166
+ if !available {
167
+ return &funcapi.FunctionResponse{Status: 503, Message: "performance_schema is not enabled"}
168
+ }
169
+
170
+ source, err := f.router.collector.detectMySQLErrorHistorySource(ctx)
171
+ if err != nil {
172
+ return &funcapi.FunctionResponse{Status: 503, Message: fmt.Sprintf("error-info not enabled: %v", err)}
173
+ }
174
+ if source.status != mysqlErrorAttrEnabled {
175
+ msg := "error-info not enabled"
176
+ if source.reason != "" {
177
+ msg = fmt.Sprintf("%s: %s", msg, source.reason)
178
+ }
179
+ return &funcapi.FunctionResponse{Status: 503, Message: msg}
180
+ }
181
+
182
+ limit := f.router.collector.TopQueriesLimit
183
+ if limit <= 0 {
184
+ limit = 500
185
+ }
186
+
187
+ rows, err := f.router.collector.fetchMySQLErrorRows(ctx, source, nil, limit)
188
+ if err != nil {
189
+ return &funcapi.FunctionResponse{Status: 500, Message: fmt.Sprintf("error-info query failed: %v", err)}
190
+ }
191
+ if len(rows) == 0 && source.fallbackTable != "" {
192
+ fallback, ferr := f.router.collector.buildMySQLErrorSource(ctx, source.fallbackTable)
193
+ if ferr == nil && fallback.status == mysqlErrorAttrEnabled {
194
+ fallbackRows, ferr := f.router.collector.fetchMySQLErrorRows(ctx, fallback, nil, limit)
195
+ if ferr == nil && len(fallbackRows) > 0 {
196
+ rows = fallbackRows
197
+ }
198
+ }
199
+ }
200
+
201
+ data := make([][]any, 0, len(rows))
202
+ for i := range rows {
203
+ row := make([]any, len(errorInfoColumns))
204
+ for j, col := range errorInfoColumns {
205
+ row[j] = col.Value(&rows[i])
206
+ }
207
+ data = append(data, row)
208
+ }
209
+
210
+ cs := errorInfoColumnSet(errorInfoColumns)
211
+
212
+ return &funcapi.FunctionResponse{
213
+ Status: 200,
214
+ Help: "Recent SQL errors from performance_schema statement history tables",
215
+ Columns: cs.BuildColumns(),
216
+ Data: data,
217
+ DefaultSortColumn: "errorNumber",
218
+ }
219
+}
220
+
221
+func (f *funcErrorInfo) checkPerformanceSchema(ctx context.Context) (bool, error) {
222
+ c := f.router.collector
223
+
224
+ c.varPerfSchemaMu.RLock()
225
+ cached := c.varPerformanceSchema
226
+ c.varPerfSchemaMu.RUnlock()
227
+ if cached != "" {
228
+ return cached == "ON" || cached == "1", nil
229
+ }
230
+
231
+ c.varPerfSchemaMu.Lock()
232
+ defer c.varPerfSchemaMu.Unlock()
233
+
234
+ if c.varPerformanceSchema != "" {
235
+ return c.varPerformanceSchema == "ON" || c.varPerformanceSchema == "1", nil
236
+ }
237
+
238
+ var value string
239
+ query := "SELECT @@performance_schema"
240
+ if err := c.db.QueryRowContext(ctx, query).Scan(&value); err != nil {
241
+ return false, err
242
+ }
243
+
244
+ c.varPerformanceSchema = value
245
+ return value == "ON" || value == "1", nil
246
+}
247
+
248
type mysqlErrorSource struct {
249
table string
250
fallbackTable string
@@ -132,6 +316,12 @@ func mysqlErrorAttributionColumns() []topQueriesColumn {
316
}
317
}
318
319
+// TODO: Refactor error data access into a shared mysqlErrorData type.
320
+// Currently these methods live on Collector because they're used by both:
321
+// - funcErrorInfo (for error-info function)
322
+// - funcTopQueries (for error attribution columns)
323
+// A cleaner design would be a mysqlErrorData type on funcRouter that both handlers use.
324
+
325
func (c *Collector) collectMySQLErrorDetailsForDigests(ctx context.Context, digests []string) (string, map[string]mysqlErrorRow) {
326
source, err := c.detectMySQLErrorHistorySource(ctx)
327
if err != nil {
@@ -170,167 +360,6 @@ func (c *Collector) collectMySQLErrorDetailsForDigests(ctx context.Context, dige
360
return mysqlErrorAttrEnabled, out
361
}
362
173
-func (c *Collector) errorInfoParams(context.Context) ([]funcapi.ParamConfig, error) {
174
- if !c.Config.GetErrorInfoFunctionEnabled() {
175
- return nil, fmt.Errorf("error-info function disabled in configuration")
176
- }
177
- return []funcapi.ParamConfig{}, nil
178
-}
179
-
180
-func (c *Collector) collectErrorInfo(ctx context.Context) *funcapi.FunctionResponse {
181
- if !c.Config.GetErrorInfoFunctionEnabled() {
182
- return &funcapi.FunctionResponse{
183
- Status: 503,
184
- Message: "error-info not enabled: function disabled in configuration. " +
185
- "To enable, set error_info_function_enabled: true in the MySQL collector config.",
186
- }
187
- }
188
-
189
- available, err := c.checkPerformanceSchema(ctx)
190
- if err != nil {
191
- return &funcapi.FunctionResponse{
192
- Status: 500,
193
- Message: fmt.Sprintf("failed to check performance_schema availability: %v", err),
194
- }
195
- }
196
- if !available {
197
- return &funcapi.FunctionResponse{Status: 503, Message: "performance_schema is not enabled"}
198
- }
199
-
200
- source, err := c.detectMySQLErrorHistorySource(ctx)
201
- if err != nil {
202
- return &funcapi.FunctionResponse{Status: 503, Message: fmt.Sprintf("error-info not enabled: %v", err)}
203
- }
204
- if source.status != mysqlErrorAttrEnabled {
205
- msg := "error-info not enabled"
206
- if source.reason != "" {
207
- msg = fmt.Sprintf("%s: %s", msg, source.reason)
208
- }
209
- return &funcapi.FunctionResponse{Status: 503, Message: msg}
210
- }
211
-
212
- limit := c.TopQueriesLimit
213
- if limit <= 0 {
214
- limit = 500
215
- }
216
-
217
- rows, err := c.fetchMySQLErrorRows(ctx, source, nil, limit)
218
- if err != nil {
219
- return &funcapi.FunctionResponse{Status: 500, Message: fmt.Sprintf("error-info query failed: %v", err)}
220
- }
221
- if len(rows) == 0 && source.fallbackTable != "" {
222
- fallback, ferr := c.buildMySQLErrorSource(ctx, source.fallbackTable)
223
- if ferr == nil && fallback.status == mysqlErrorAttrEnabled {
224
- fallbackRows, ferr := c.fetchMySQLErrorRows(ctx, fallback, nil, limit)
225
- if ferr == nil && len(fallbackRows) > 0 {
226
- rows = fallbackRows
227
- }
228
- }
229
- }
230
-
231
- data := make([][]any, 0, len(rows))
232
- for _, row := range rows {
233
- var errNo any
234
- if row.ErrorNumber != nil {
235
- errNo = *row.ErrorNumber
236
- }
237
- data = append(data, []any{
238
- row.Digest,
239
- row.Query,
240
- row.Schema,
241
- errNo,
242
- row.SQLState,
243
- row.Message,
244
- })
245
- }
246
-
247
- return &funcapi.FunctionResponse{
248
- Status: 200,
249
- Help: "Recent SQL errors from performance_schema statement history tables",
250
- Columns: buildMySQLErrorInfoColumns(),
251
- Data: data,
252
- DefaultSortColumn: "errorNumber",
253
- }
254
-}
255
-
256
-func buildMySQLErrorInfoColumns() map[string]any {
257
- columns := map[string]any{
258
- "digest": funcapi.Column{
259
- Index: 0,
260
- Name: "Digest",
261
- Type: funcapi.FieldTypeString,
262
- Sortable: true,
263
- Visible: false,
264
- UniqueKey: true,
265
- ValueOptions: funcapi.ValueOptions{Transform: funcapi.FieldTransformNone},
266
- }.BuildColumn(),
267
- "query": funcapi.Column{
268
- Index: 1,
269
- Name: "Query",
270
- Type: funcapi.FieldTypeString,
271
- Sortable: true,
272
- Sticky: true,
273
- FullWidth: true,
274
- ValueOptions: funcapi.ValueOptions{Transform: funcapi.FieldTransformNone},
275
- }.BuildColumn(),
276
- "schema": funcapi.Column{
277
- Index: 2,
278
- Name: "Schema",
279
- Type: funcapi.FieldTypeString,
280
- Sortable: true,
281
- ValueOptions: funcapi.ValueOptions{Transform: funcapi.FieldTransformNone},
282
- }.BuildColumn(),
283
- "errorNumber": funcapi.Column{
284
- Index: 3,
285
- Name: "Error Number",
286
- Type: funcapi.FieldTypeInteger,
287
- Sortable: true,
288
- ValueOptions: funcapi.ValueOptions{Transform: funcapi.FieldTransformNumber},
289
- }.BuildColumn(),
290
- "sqlState": funcapi.Column{
291
- Index: 4,
292
- Name: "SQL State",
293
- Type: funcapi.FieldTypeString,
294
- Sortable: true,
295
- ValueOptions: funcapi.ValueOptions{Transform: funcapi.FieldTransformNone},
296
- }.BuildColumn(),
297
- "errorMessage": funcapi.Column{
298
- Index: 5,
299
- Name: "Error Message",
300
- Type: funcapi.FieldTypeString,
301
- Sortable: false,
302
- FullWidth: true,
303
- ValueOptions: funcapi.ValueOptions{Transform: funcapi.FieldTransformNone},
304
- }.BuildColumn(),
305
- }
306
- return columns
307
-}
308
-
309
-func (c *Collector) checkPerformanceSchema(ctx context.Context) (bool, error) {
310
- c.varPerfSchemaMu.RLock()
311
- cached := c.varPerformanceSchema
312
- c.varPerfSchemaMu.RUnlock()
313
- if cached != "" {
314
- return cached == "ON" || cached == "1", nil
315
- }
316
-
317
- c.varPerfSchemaMu.Lock()
318
- defer c.varPerfSchemaMu.Unlock()
319
-
320
- if c.varPerformanceSchema != "" {
321
- return c.varPerformanceSchema == "ON" || c.varPerformanceSchema == "1", nil
322
- }
323
-
324
- var value string
325
- query := "SELECT @@performance_schema"
326
- if err := c.db.QueryRowContext(ctx, query).Scan(&value); err != nil {
327
- return false, err
328
- }
329
-
330
- c.varPerformanceSchema = value
331
- return value == "ON" || value == "1", nil
332
-}
333
-
363
func (c *Collector) detectMySQLErrorHistorySource(ctx context.Context) (mysqlErrorSource, error) {
364
qctx, cancel := context.WithTimeout(ctx, c.Timeout.Duration())
365
defer cancel()
@@ -346,7 +375,6 @@ WHERE NAME IN ('events_statements_history_long','events_statements_history','eve
375
defer rows.Close()
376
377
enabled := map[string]bool{}
349
- present := map[string]bool{}
378
for rows.Next() {
379
var name, enabledVal string
380
if err := rows.Scan(&name, &enabledVal); err != nil {
@@ -354,7 +382,6 @@ WHERE NAME IN ('events_statements_history_long','events_statements_history','eve
382
}
383
key := strings.ToLower(name)
384
enabled[key] = strings.EqualFold(enabledVal, "YES")
357
- present[key] = true
385
}
386
if err := rows.Err(); err != nil {
387
return mysqlErrorSource{status: mysqlErrorAttrNotEnabled, reason: "unable to read performance_schema.setup_consumers"}, err
@@ -442,8 +469,15 @@ func (c *Collector) fetchMySQLErrorRows(ctx context.Context, source mysqlErrorSo
469
if source.columns["DIGEST_TEXT"] {
470
selectCols = append(selectCols, "DIGEST_TEXT")
471
}
472
+ // MySQL uses SCHEMA_NAME, MariaDB uses CURRENT_SCHEMA
473
+ schemaCol := ""
474
if source.columns["SCHEMA_NAME"] {
446
- selectCols = append(selectCols, "SCHEMA_NAME")
475
+ schemaCol = "SCHEMA_NAME"
476
+ } else if source.columns["CURRENT_SCHEMA"] {
477
+ schemaCol = "CURRENT_SCHEMA"
478
+ }
479
+ if schemaCol != "" {
480
+ selectCols = append(selectCols, schemaCol)
481
}
482
if source.columns["SQL_TEXT"] {
483
selectCols = append(selectCols, "SQL_TEXT")
@@ -509,7 +543,7 @@ func (c *Collector) fetchMySQLErrorRows(ctx context.Context, source mysqlErrorSo
543
if source.columns["DIGEST_TEXT"] {
544
scanTargets = append(scanTargets, &digestText)
545
}
512
- if source.columns["SCHEMA_NAME"] {
546
+ if schemaCol != "" {
547
scanTargets = append(scanTargets, &schemaName)
548
}
549
if source.columns["SQL_TEXT"] {
@@ -520,13 +554,21 @@ func (c *Collector) fetchMySQLErrorRows(ctx context.Context, source mysqlErrorSo
554
return nil, err
555
}
556
523
- if !digest.Valid || strings.TrimSpace(digest.String) == "" {
557
+ digestKey := ""
558
+ if digest.Valid && strings.TrimSpace(digest.String) != "" {
559
+ digestKey = digest.String
560
+ } else if sqlText.Valid && strings.TrimSpace(sqlText.String) != "" {
561
+ // Generate synthetic digest from SQL_TEXT + MYSQL_ERRNO when DIGEST is NULL.
562
+ // This happens for statements that fail during parsing (syntax errors, etc.).
563
+ digestKey = generateSyntheticDigest(sqlText.String, errno.Int64)
564
+ } else {
565
continue
566
}
526
- if seen[digest.String] {
567
+
568
+ if seen[digestKey] {
569
continue
570
}
529
- seen[digest.String] = true
571
+ seen[digestKey] = true
572
573
queryText := ""
574
switch {
@@ -543,7 +585,7 @@ func (c *Collector) fetchMySQLErrorRows(ctx context.Context, source mysqlErrorSo
585
}
586
587
row := mysqlErrorRow{
546
- Digest: digest.String,
588
+ Digest: digestKey,
589
Query: queryText,
590
Schema: schemaName.String,
591
ErrorNumber: errNoPtr,
@@ -560,6 +602,17 @@ func (c *Collector) fetchMySQLErrorRows(ctx context.Context, source mysqlErrorSo
602
return results, nil
603
}
604
605
+// generateSyntheticDigest creates a digest-like identifier for error rows
606
+// where the real DIGEST is NULL (e.g., syntax errors that fail during parsing).
607
+// It combines SQL_TEXT and MYSQL_ERRNO to provide meaningful deduplication.
608
+func generateSyntheticDigest(sqlText string, errno int64) string {
609
+ h := md5.New()
610
+ h.Write([]byte(sqlText))
611
+ h.Write([]byte("_"))
612
+ h.Write([]byte(strconv.FormatInt(errno, 10)))
613
+ return hex.EncodeToString(h.Sum(nil))
614
+}
615
+
616
func nullableString(value string) any {
617
if strings.TrimSpace(value) == "" {
618
return nil