@cryptotaxi247 / netdata-1 / commits / f270a8707

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