@cryptotaxi247 / netdata-1 / commits / afd66a995

feat(go.d.plugin/mssql): improve deadlock-info and error-info functions (#21652)

Ilya Mashchenko committed Jan 27, 2026 at 22:27 UTC afd66a9952a783b2cb518a84d110020e2f772aad
9 files changed +653 -490
src/go/plugin/go.d/collector/mssql/collector.go
+32
@@ -80,6 +80,14 @@ type Config struct {
80 // Default: true
81 DeadlockInfoFunctionEnabled *bool `yaml:"deadlock_info_function_enabled,omitempty" json:"deadlock_info_function_enabled"`
82
83 + // DeadlockInfoUseRingBuffer uses ring_buffer target instead of event_file for deadlock-info
84 + // Uses pointer to distinguish "unset" from explicit "true":
85 + // - nil (unset): Apply default of false (use event_file)
86 + // - false: Use event_file target (faster, recommended for on-prem)
87 + // - true: Use ring_buffer target (required for Azure SQL DB without blob storage)
88 + // Default: false
89 + DeadlockInfoUseRingBuffer *bool `yaml:"deadlock_info_use_ring_buffer,omitempty" json:"deadlock_info_use_ring_buffer"`
90 +
91 // ErrorInfoFunctionEnabled controls whether the error-info function is available
92 // Uses pointer to distinguish "unset" from explicit "false":
93 // - nil (unset): Apply default of true (enabled)
@@ -92,6 +100,14 @@ type Config struct {
100 // Default: "netdata_errors"
101 ErrorInfoSessionName string `yaml:"error_info_session_name,omitempty" json:"error_info_session_name,omitempty"`
102
103 + // ErrorInfoUseRingBuffer uses ring_buffer target instead of event_file for error-info
104 + // Uses pointer to distinguish "unset" from explicit "true":
105 + // - nil (unset): Apply default of false (use event_file)
106 + // - false: Use event_file target (faster, recommended for on-prem)
107 + // - true: Use ring_buffer target (required for Azure SQL DB without blob storage)
108 + // Default: false
109 + ErrorInfoUseRingBuffer *bool `yaml:"error_info_use_ring_buffer,omitempty" json:"error_info_use_ring_buffer"`
110 +
111 // TopQueriesLimit is the maximum number of queries to return
112 TopQueriesLimit int `yaml:"top_queries_limit,omitempty" json:"top_queries_limit,omitempty"`
113 }
@@ -120,6 +136,14 @@ func (c *Config) GetDeadlockInfoFunctionEnabled() bool {
136 return *c.DeadlockInfoFunctionEnabled
137 }
138
139 +// GetDeadlockInfoUseRingBuffer returns whether to use ring_buffer target instead of event_file (default: false)
140 +func (c *Config) GetDeadlockInfoUseRingBuffer() bool {
141 + if c.DeadlockInfoUseRingBuffer == nil {
142 + return false
143 + }
144 + return *c.DeadlockInfoUseRingBuffer
145 +}
146 +
147 // GetErrorInfoFunctionEnabled returns whether the error-info function is enabled (default: true)
148 func (c *Config) GetErrorInfoFunctionEnabled() bool {
149 if c.ErrorInfoFunctionEnabled == nil {
@@ -136,6 +160,14 @@ func (c *Config) GetErrorInfoSessionName() string {
160 return c.ErrorInfoSessionName
161 }
162
163 +// GetErrorInfoUseRingBuffer returns whether to use ring_buffer target instead of event_file (default: false)
164 +func (c *Config) GetErrorInfoUseRingBuffer() bool {
165 + if c.ErrorInfoUseRingBuffer == nil {
166 + return false
167 + }
168 + return *c.ErrorInfoUseRingBuffer
169 +}
170 +
171 type Collector struct {
172 module.Base
173 Config `yaml:",inline" json:""`
src/go/plugin/go.d/collector/mssql/config_schema.json
+20 -2
@@ -57,6 +57,12 @@
57 "type": "boolean",
58 "default": true
59 },
60 + "deadlock_info_use_ring_buffer": {
61 + "title": "Use Ring Buffer for Deadlock Info",
62 + "description": "Use ring_buffer target instead of event_file for system_health session. Enable for Azure SQL Database without blob storage. Note: ring_buffer can be slower with large buffers.",
63 + "type": "boolean",
64 + "default": false
65 + },
66 "error_info_function_enabled": {
67 "title": "Enable Error Info Function",
68 "description": "Enable the error-info function. WARNING: error messages and query text may contain unmasked sensitive literals (PII). This function reads from a user-managed Extended Events session and requires VIEW SERVER STATE.",
@@ -65,9 +71,15 @@
71 },
72 "error_info_session_name": {
73 "title": "Error Info Session Name",
68 - "description": "Name of the Extended Events session that captures error_reported events for error-info. The session must be created by an administrator and include a ring_buffer target.",
74 + "description": "Name of the Extended Events session that captures error_reported events for error-info. The session must be created by an administrator and include an event_file target.",
75 "type": "string",
76 "default": "netdata_errors"
77 + },
78 + "error_info_use_ring_buffer": {
79 + "title": "Use Ring Buffer for Error Info",
80 + "description": "Use ring_buffer target instead of event_file. Enable for Azure SQL Database without blob storage. Note: ring_buffer can be slower with large buffers.",
81 + "type": "boolean",
82 + "default": false
83 }
84 },
85 "required": [
@@ -100,11 +112,17 @@
112 "deadlock_info_function_enabled": {
113 "ui:help": "When enabled, the deadlock-info function becomes available in the Netdata dashboard. WARNING: query text may contain unmasked sensitive literals and requires VIEW SERVER STATE permission."
114 },
115 + "deadlock_info_use_ring_buffer": {
116 + "ui:help": "Enable for Azure SQL Database or environments without event_file access. Ring buffer can be slower with large buffers."
117 + },
118 "error_info_function_enabled": {
119 "ui:help": "When enabled, the error-info function becomes available in the Netdata dashboard. WARNING: error messages and query text may include sensitive literals."
120 },
121 "error_info_session_name": {
107 - "ui:help": "The Extended Events session must be created by an administrator and include a ring_buffer target capturing sqlserver.error_reported with sql_text action."
122 + "ui:help": "The Extended Events session must be created by an administrator and include an event_file target capturing sqlserver.error_reported with sql_text action."
123 + },
124 + "error_info_use_ring_buffer": {
125 + "ui:help": "Enable for Azure SQL Database or environments without event_file access. Ring buffer can be slower with large buffers."
126 },
127 "ui:flavour": "tabs",
128 "ui:options": {
src/go/plugin/go.d/collector/mssql/func_deadlock_info.go renamed
+337 -343
@@ -20,12 +20,208 @@ import (
20 )
21
22 const (
23 - deadlockInfoHelp = "Latest deadlock from the system_health Extended Events ring buffer. WARNING: query text may include unmasked sensitive literals; restrict dashboard access."
23 + deadlockInfoHelp = "Latest deadlock from the system_health Extended Events session. WARNING: query text may include unmasked sensitive literals; restrict dashboard access."
24 deadlockParseErrorStatus = 561
25 )
26
27 const deadlockInfoMethodID = "deadlock-info"
28
29 +// deadlockRowData holds computed values for a single deadlock row.
30 +type deadlockRowData struct {
31 + rowID string
32 + deadlockID string
33 + timestamp string
34 + processID string
35 + spid any
36 + ecid any
37 + isVictim string
38 + queryText string
39 + lockMode string
40 + lockStatus string
41 + waitResource string
42 + database any
43 +}
44 +
45 +// deadlockColumn defines a column for the deadlock-info function.
46 +type deadlockColumn struct {
47 + funcapi.ColumnMeta
48 + Value func(*deadlockRowData) any
49 +}
50 +
51 +func deadlockColumnSet(cols []deadlockColumn) funcapi.ColumnSet[deadlockColumn] {
52 + return funcapi.Columns(cols, func(c deadlockColumn) funcapi.ColumnMeta { return c.ColumnMeta })
53 +}
54 +
55 +var deadlockColumns = []deadlockColumn{
56 + {
57 + ColumnMeta: funcapi.ColumnMeta{
58 + Name: "row_id",
59 + Tooltip: "Unique identifier for this row",
60 + Type: funcapi.FieldTypeString,
61 + Sort: funcapi.FieldSortAscending,
62 + Sortable: true,
63 + Summary: funcapi.FieldSummaryCount,
64 + Filter: funcapi.FieldFilterMultiselect,
65 + UniqueKey: true,
66 + Visible: false,
67 + },
68 + Value: func(r *deadlockRowData) any { return r.rowID },
69 + },
70 + {
71 + ColumnMeta: funcapi.ColumnMeta{
72 + Name: "timestamp",
73 + Tooltip: "When the deadlock occurred",
74 + Type: funcapi.FieldTypeTimestamp,
75 + Sort: funcapi.FieldSortDescending,
76 + Sortable: true,
77 + Summary: funcapi.FieldSummaryMax,
78 + Filter: funcapi.FieldFilterRange,
79 + Visible: true,
80 + Transform: funcapi.FieldTransformDatetime,
81 + },
82 + Value: func(r *deadlockRowData) any { return r.timestamp },
83 + },
84 + {
85 + ColumnMeta: funcapi.ColumnMeta{
86 + Name: "is_victim",
87 + Tooltip: "Whether this process was rolled back to resolve the deadlock",
88 + Type: funcapi.FieldTypeString,
89 + Visualization: funcapi.FieldVisualPill,
90 + Sort: funcapi.FieldSortAscending,
91 + Sortable: true,
92 + Summary: funcapi.FieldSummaryCount,
93 + Filter: funcapi.FieldFilterMultiselect,
94 + Visible: true,
95 + },
96 + Value: func(r *deadlockRowData) any { return r.isVictim },
97 + },
98 + {
99 + ColumnMeta: funcapi.ColumnMeta{
100 + Name: "query_text",
101 + Tooltip: "The SQL statement being executed",
102 + Type: funcapi.FieldTypeString,
103 + Sort: funcapi.FieldSortAscending,
104 + Sortable: false,
105 + Sticky: true,
106 + Summary: funcapi.FieldSummaryCount,
107 + Filter: funcapi.FieldFilterMultiselect,
108 + FullWidth: true,
109 + Wrap: true,
110 + Visible: true,
111 + },
112 + Value: func(r *deadlockRowData) any { return r.queryText },
113 + },
114 + {
115 + ColumnMeta: funcapi.ColumnMeta{
116 + Name: "database",
117 + Tooltip: "Database where the deadlock occurred",
118 + Type: funcapi.FieldTypeString,
119 + Sort: funcapi.FieldSortAscending,
120 + Sortable: true,
121 + Summary: funcapi.FieldSummaryCount,
122 + Filter: funcapi.FieldFilterMultiselect,
123 + Visible: true,
124 + },
125 + Value: func(r *deadlockRowData) any { return r.database },
126 + },
127 + {
128 + ColumnMeta: funcapi.ColumnMeta{
129 + Name: "lock_mode",
130 + Tooltip: "Type of lock (S=Shared, X=Exclusive, U=Update, etc.)",
131 + Type: funcapi.FieldTypeString,
132 + Sort: funcapi.FieldSortAscending,
133 + Sortable: true,
134 + Summary: funcapi.FieldSummaryCount,
135 + Filter: funcapi.FieldFilterMultiselect,
136 + Visible: true,
137 + },
138 + Value: func(r *deadlockRowData) any { return r.lockMode },
139 + },
140 + {
141 + ColumnMeta: funcapi.ColumnMeta{
142 + Name: "lock_status",
143 + Tooltip: "Whether the lock was granted or still waiting",
144 + Type: funcapi.FieldTypeString,
145 + Visualization: funcapi.FieldVisualPill,
146 + Sort: funcapi.FieldSortAscending,
147 + Sortable: true,
148 + Summary: funcapi.FieldSummaryCount,
149 + Filter: funcapi.FieldFilterMultiselect,
150 + Visible: true,
151 + },
152 + Value: func(r *deadlockRowData) any { return r.lockStatus },
153 + },
154 + {
155 + ColumnMeta: funcapi.ColumnMeta{
156 + Name: "wait_resource",
157 + Tooltip: "The resource this process was waiting to acquire",
158 + Type: funcapi.FieldTypeString,
159 + Sort: funcapi.FieldSortAscending,
160 + Sortable: false,
161 + Summary: funcapi.FieldSummaryCount,
162 + Filter: funcapi.FieldFilterMultiselect,
163 + FullWidth: true,
164 + Wrap: true,
165 + Visible: true,
166 + },
167 + Value: func(r *deadlockRowData) any { return r.waitResource },
168 + },
169 + {
170 + ColumnMeta: funcapi.ColumnMeta{
171 + Name: "spid",
172 + Tooltip: "Server Process ID (SQL Server session ID)",
173 + Type: funcapi.FieldTypeInteger,
174 + Sort: funcapi.FieldSortAscending,
175 + Sortable: true,
176 + Summary: funcapi.FieldSummaryCount,
177 + Filter: funcapi.FieldFilterRange,
178 + Visible: true,
179 + Transform: funcapi.FieldTransformNumber,
180 + },
181 + Value: func(r *deadlockRowData) any { return r.spid },
182 + },
183 + {
184 + ColumnMeta: funcapi.ColumnMeta{
185 + Name: "ecid",
186 + Tooltip: "Execution Context ID for parallel query threads",
187 + Type: funcapi.FieldTypeInteger,
188 + Sort: funcapi.FieldSortAscending,
189 + Sortable: true,
190 + Summary: funcapi.FieldSummaryCount,
191 + Filter: funcapi.FieldFilterRange,
192 + Visible: true,
193 + Transform: funcapi.FieldTransformNumber,
194 + },
195 + Value: func(r *deadlockRowData) any { return r.ecid },
196 + },
197 + {
198 + ColumnMeta: funcapi.ColumnMeta{
199 + Name: "process_id",
200 + Tooltip: "Internal process identifier from the deadlock graph",
201 + Type: funcapi.FieldTypeString,
202 + Sort: funcapi.FieldSortAscending,
203 + Sortable: true,
204 + Summary: funcapi.FieldSummaryCount,
205 + Filter: funcapi.FieldFilterMultiselect,
206 + Visible: true,
207 + },
208 + Value: func(r *deadlockRowData) any { return r.processID },
209 + },
210 + {
211 + ColumnMeta: funcapi.ColumnMeta{
212 + Name: "deadlock_id",
213 + Tooltip: "Unique identifier for this deadlock event",
214 + Type: funcapi.FieldTypeString,
215 + Sort: funcapi.FieldSortAscending,
216 + Sortable: true,
217 + Summary: funcapi.FieldSummaryCount,
218 + Filter: funcapi.FieldFilterMultiselect,
219 + Visible: true,
220 + },
221 + Value: func(r *deadlockRowData) any { return r.deadlockID },
222 + },
223 +}
224 +
225 func deadlockInfoMethodConfig() funcapi.MethodConfig {
226 return funcapi.MethodConfig{
227 ID: deadlockInfoMethodID,
@@ -64,108 +260,13 @@ func (f *funcDeadlockInfo) Handle(ctx context.Context, method string, params fun
260 }
261 f.router.collector.db = db
262 }
67 - return f.router.collector.collectDeadlockInfo(ctx)
263 + return f.collectData(ctx)
264 }
265
266 func (f *funcDeadlockInfo) Cleanup(ctx context.Context) {}
267
72 -const (
73 - deadlockIdxRowID = iota
74 - deadlockIdxDeadlockID
75 - deadlockIdxTimestamp
76 - deadlockIdxProcessID
77 - deadlockIdxSpid
78 - deadlockIdxEcid
79 - deadlockIdxIsVictim
80 - deadlockIdxQueryText
81 - deadlockIdxLockMode
82 - deadlockIdxLockStatus
83 - deadlockIdxWaitResource
84 - deadlockIdxDatabase
85 - deadlockColumnCount
86 -)
87 -
88 -type mssqlDeadlockTxn struct {
89 - processID string
90 - spid string
91 - ecid string
92 - dbid string
93 - queryText string
94 - lockMode string
95 - lockStatus string
96 - waitResource string
97 -}
98 -
99 -type mssqlDeadlockParseResult struct {
100 - deadlockTime time.Time
101 - transactions []*mssqlDeadlockTxn
102 - victimProcessID string
103 - parseErr error
104 - found bool
105 -}
106 -
107 -type mssqlDeadlockGraph struct {
108 - XMLName xml.Name `xml:"deadlock"`
109 - VictimList mssqlDeadlockVictimList `xml:"victim-list"`
110 - ProcessList mssqlDeadlockProcessList `xml:"process-list"`
111 - ResourceList mssqlDeadlockResourceList `xml:"resource-list"`
112 -}
113 -
114 -type mssqlDeadlockResourceList struct {
115 - Resources []mssqlDeadlockResource `xml:",any"`
116 -}
117 -
118 -type mssqlDeadlockVictimList struct {
119 - Victims []mssqlDeadlockVictim `xml:"victimProcess"`
120 -}
121 -
122 -type mssqlDeadlockVictim struct {
123 - ID string `xml:"id,attr"`
124 -}
125 -
126 -type mssqlDeadlockProcessList struct {
127 - Processes []mssqlDeadlockProcess `xml:"process"`
128 -}
129 -
130 -type mssqlDeadlockProcess struct {
131 - ID string `xml:"id,attr"`
132 - SPID string `xml:"spid,attr"`
133 - ECID string `xml:"ecid,attr"`
134 - DBID string `xml:"dbid,attr"`
135 - LockMode string `xml:"lockMode,attr"`
136 - WaitResource string `xml:"waitresource,attr"`
137 - InputBuf string `xml:"inputbuf"`
138 -}
139 -
140 -type mssqlDeadlockResource struct {
141 - XMLName xml.Name
142 - DBID string `xml:"dbid,attr"`
143 - OwnerList mssqlDeadlockOwnerList `xml:"owner-list"`
144 - WaiterList mssqlDeadlockWaiterList `xml:"waiter-list"`
145 -}
146 -
147 -type mssqlDeadlockOwnerList struct {
148 - Owners []mssqlDeadlockResourceEntry `xml:"owner"`
149 -}
150 -
151 -type mssqlDeadlockWaiterList struct {
152 - Waiters []mssqlDeadlockResourceEntry `xml:"waiter"`
153 -}
154 -
155 -type mssqlDeadlockResourceEntry struct {
156 - ID string `xml:"id,attr"`
157 - Mode string `xml:"mode,attr"`
158 -}
159 -
160 -func (c *Collector) deadlockInfoParams(context.Context) ([]funcapi.ParamConfig, error) {
161 - if !c.Config.GetDeadlockInfoFunctionEnabled() {
162 - return nil, fmt.Errorf("deadlock-info function disabled in configuration")
163 - }
164 - return []funcapi.ParamConfig{}, nil
165 -}
166 -
167 -func (c *Collector) collectDeadlockInfo(ctx context.Context) *funcapi.FunctionResponse {
168 - if !c.Config.GetDeadlockInfoFunctionEnabled() {
268 +func (f *funcDeadlockInfo) collectData(ctx context.Context) *funcapi.FunctionResponse {
269 + if !f.router.collector.Config.GetDeadlockInfoFunctionEnabled() {
270 return &funcapi.FunctionResponse{
271 Status: 503,
272 Message: "deadlock-info function has been disabled in configuration. " +
@@ -173,67 +274,82 @@ func (c *Collector) collectDeadlockInfo(ctx context.Context) *funcapi.FunctionRe
274 }
275 }
276
176 - deadlockTime, deadlockXML, err := c.queryLatestDeadlock(ctx)
277 + deadlockTime, deadlockXML, err := f.queryLatestDeadlock(ctx)
278 if err != nil {
279 if errors.Is(err, context.DeadlineExceeded) {
179 - return c.deadlockInfoResponse(504, "deadlock query timed out", nil)
280 + return f.buildResponse(504, "deadlock query timed out", nil)
281 }
282 if isDeadlockPermissionError(err) {
182 - return c.deadlockInfoResponse(403, deadlockPermissionMessage(), nil)
283 + return f.buildResponse(403, deadlockPermissionMessage(), nil)
284 }
184 - c.Warningf("deadlock-info: query failed: %v", err)
185 - return c.deadlockInfoResponse(500, fmt.Sprintf("deadlock query failed: %v", err), nil)
285 + f.router.collector.Warningf("deadlock-info: query failed: %v", err)
286 + return f.buildResponse(500, fmt.Sprintf("deadlock query failed: %v", err), nil)
287 }
288
289 if deadlockXML == "" {
189 - return c.deadlockInfoResponse(200, "no deadlock found in system_health ring buffer", nil)
290 + return f.buildResponse(200, "no deadlock found in system_health Extended Events", nil)
291 }
292
192 - dbNames, dbErr := c.queryDatabaseNames(ctx)
293 + dbNames, dbErr := f.queryDatabaseNames(ctx)
294 if dbErr != nil {
194 - c.Debugf("deadlock-info: database name mapping failed: %v", dbErr)
295 + f.router.collector.Debugf("deadlock-info: database name mapping failed: %v", dbErr)
296 dbNames = map[int]string{}
297 }
298
299 parseRes := parseDeadlockGraph(deadlockXML, deadlockTime)
300 if parseRes.parseErr != nil {
200 - c.Warningf("deadlock-info: parse failed: %v", parseRes.parseErr)
201 - return c.deadlockInfoResponse(deadlockParseErrorStatus, "deadlock graph could not be parsed", nil)
301 + f.router.collector.Warningf("deadlock-info: parse failed: %v", parseRes.parseErr)
302 + return f.buildResponse(deadlockParseErrorStatus, "deadlock graph could not be parsed", nil)
303 }
304 +
305 if !parseRes.found {
204 - return c.deadlockInfoResponse(200, "no deadlock found in system_health ring buffer", nil)
306 + return f.buildResponse(200, "no deadlock found in system_health Extended Events", nil)
307 }
308
309 deadlockID := generateDeadlockID(parseRes.deadlockTime)
310 rows := buildDeadlockRows(parseRes, deadlockID, dbNames)
311 +
312 if len(rows) == 0 {
210 - return c.deadlockInfoResponse(200, "deadlock detected but no processes could be parsed", nil)
313 + return f.buildResponse(200, "deadlock detected but no processes could be parsed", nil)
314 }
315
213 - return c.deadlockInfoResponse(200, "latest detected deadlock", rows)
316 + return f.buildResponse(200, "latest detected deadlock", rows)
317 }
318
216 -func (c *Collector) deadlockInfoResponse(status int, message string, data [][]any) *funcapi.FunctionResponse {
217 - if data == nil {
218 - data = make([][]any, 0)
319 +func (f *funcDeadlockInfo) buildResponse(status int, message string, rowsData []deadlockRowData) *funcapi.FunctionResponse {
320 + data := make([][]any, 0, len(rowsData))
321 + for i := range rowsData {
322 + row := make([]any, len(deadlockColumns))
323 + for j, col := range deadlockColumns {
324 + row[j] = col.Value(&rowsData[i])
325 + }
326 + data = append(data, row)
327 }
328 +
329 + cs := deadlockColumnSet(deadlockColumns)
330 +
331 return &funcapi.FunctionResponse{
332 Status: status,
333 Help: deadlockInfoHelp,
334 Message: message,
224 - Columns: c.buildDeadlockColumns(),
335 + Columns: cs.BuildColumns(),
336 Data: data,
337 DefaultSortColumn: "timestamp",
338 }
339 }
340
230 -func (c *Collector) queryLatestDeadlock(ctx context.Context) (time.Time, string, error) {
231 - qctx, cancel := context.WithTimeout(ctx, c.Timeout.Duration())
341 +func (f *funcDeadlockInfo) queryLatestDeadlock(ctx context.Context) (time.Time, string, error) {
342 + qctx, cancel := context.WithTimeout(ctx, f.router.collector.Timeout.Duration())
343 defer cancel()
344
345 + query := querySystemHealthLatestDeadlockEventFile
346 + if f.router.collector.Config.GetDeadlockInfoUseRingBuffer() {
347 + query = querySystemHealthLatestDeadlockRingBuffer
348 + }
349 +
350 var deadlockTime sql.NullTime
351 var deadlockXML sql.NullString
236 - err := c.db.QueryRowContext(qctx, querySystemHealthLatestDeadlock).Scan(&deadlockTime, &deadlockXML)
352 + err := f.router.collector.db.QueryRowContext(qctx, query).Scan(&deadlockTime, &deadlockXML)
353 if err != nil {
354 if errors.Is(err, sql.ErrNoRows) {
355 return time.Time{}, "", nil
@@ -251,11 +367,11 @@ func (c *Collector) queryLatestDeadlock(ctx context.Context) (time.Time, string,
367 return time.Now().UTC(), deadlockXML.String, nil
368 }
369
254 -func (c *Collector) queryDatabaseNames(ctx context.Context) (map[int]string, error) {
255 - qctx, cancel := context.WithTimeout(ctx, c.Timeout.Duration())
370 +func (f *funcDeadlockInfo) queryDatabaseNames(ctx context.Context) (map[int]string, error) {
371 + qctx, cancel := context.WithTimeout(ctx, f.router.collector.Timeout.Duration())
372 defer cancel()
373
258 - rows, err := c.db.QueryContext(qctx, queryDatabaseNamesByID)
374 + rows, err := f.router.collector.db.QueryContext(qctx, queryDatabaseNamesByID)
375 if err != nil {
376 return nil, err
377 }
@@ -276,215 +392,76 @@ func (c *Collector) queryDatabaseNames(ctx context.Context) (map[int]string, err
392 return names, nil
393 }
394
279 -func (c *Collector) buildDeadlockColumns() map[string]any {
280 - const (
281 - ftString = funcapi.FieldTypeString
282 - ftInteger = funcapi.FieldTypeInteger
283 - ftTimestamp = funcapi.FieldTypeTimestamp
395 +type mssqlDeadlockTxn struct {
396 + processID string
397 + spid string
398 + ecid string
399 + dbid string
400 + queryText string
401 + lockMode string
402 + lockStatus string
403 + waitResource string
404 +}
405
285 - trNone = funcapi.FieldTransformNone
286 - trNumber = funcapi.FieldTransformNumber
287 - trDatetime = funcapi.FieldTransformDatetime
406 +type mssqlDeadlockParseResult struct {
407 + deadlockTime time.Time
408 + transactions []*mssqlDeadlockTxn
409 + victimProcessID string
410 + parseErr error
411 + found bool
412 +}
413
289 - visValue = funcapi.FieldVisualValue
290 - visPill = funcapi.FieldVisualPill
414 +type mssqlDeadlockGraph struct {
415 + XMLName xml.Name `xml:"deadlock"`
416 + VictimList mssqlDeadlockVictimList `xml:"victim-list"`
417 + ProcessList mssqlDeadlockProcessList `xml:"process-list"`
418 + ResourceList mssqlDeadlockResourceList `xml:"resource-list"`
419 +}
420
292 - sortAsc = funcapi.FieldSortAscending
293 - sortDesc = funcapi.FieldSortDescending
421 +type mssqlDeadlockResourceList struct {
422 + Resources []mssqlDeadlockResource `xml:",any"`
423 +}
424
295 - summaryCount = funcapi.FieldSummaryCount
296 - summaryMax = funcapi.FieldSummaryMax
425 +type mssqlDeadlockVictimList struct {
426 + Victims []mssqlDeadlockVictim `xml:"victimProcess"`
427 +}
428
298 - filterMulti = funcapi.FieldFilterMultiselect
299 - filterRange = funcapi.FieldFilterRange
300 - )
429 +type mssqlDeadlockVictim struct {
430 + ID string `xml:"id,attr"`
431 +}
432
302 - return map[string]any{
303 - "row_id": funcapi.Column{
304 - Index: deadlockIdxRowID,
305 - Name: "Row ID",
306 - Type: ftString,
307 - Visualization: visValue,
308 - Sort: sortAsc,
309 - Sortable: true,
310 - Summary: summaryCount,
311 - Filter: filterMulti,
312 - UniqueKey: true,
313 - Visible: false,
314 - ValueOptions: funcapi.ValueOptions{
315 - Transform: trNone,
316 - },
317 - }.BuildColumn(),
318 - "deadlock_id": funcapi.Column{
319 - Index: deadlockIdxDeadlockID,
320 - Name: "Deadlock ID",
321 - Type: ftString,
322 - Visualization: visValue,
323 - Sort: sortDesc,
324 - Sortable: true,
325 - Sticky: true,
326 - Summary: summaryCount,
327 - Filter: filterMulti,
328 - Visible: true,
329 - ValueOptions: funcapi.ValueOptions{
330 - Transform: trNone,
331 - },
332 - }.BuildColumn(),
333 - "timestamp": funcapi.Column{
334 - Index: deadlockIdxTimestamp,
335 - Name: "Timestamp",
336 - Type: ftTimestamp,
337 - Units: "",
338 - Visualization: visValue,
339 - Sort: sortDesc,
340 - Sortable: true,
341 - Sticky: true,
342 - Summary: summaryMax,
343 - Filter: filterRange,
344 - Visible: true,
345 - ValueOptions: funcapi.ValueOptions{
346 - Transform: trDatetime,
347 - },
348 - }.BuildColumn(),
349 - "process_id": funcapi.Column{
350 - Index: deadlockIdxProcessID,
351 - Name: "Process ID",
352 - Type: ftString,
353 - Visualization: visValue,
354 - Sort: sortAsc,
355 - Sortable: true,
356 - Sticky: true,
357 - Summary: summaryCount,
358 - Filter: filterMulti,
359 - Visible: true,
360 - ValueOptions: funcapi.ValueOptions{
361 - Transform: trNone,
362 - },
363 - }.BuildColumn(),
364 - "spid": funcapi.Column{
365 - Index: deadlockIdxSpid,
366 - Name: "SPID",
367 - Type: ftInteger,
368 - Visualization: visValue,
369 - Sort: sortAsc,
370 - Sortable: true,
371 - Sticky: false,
372 - Summary: summaryCount,
373 - Filter: filterRange,
374 - Visible: true,
375 - ValueOptions: funcapi.ValueOptions{
376 - Transform: trNumber,
377 - },
378 - }.BuildColumn(),
379 - "ecid": funcapi.Column{
380 - Index: deadlockIdxEcid,
381 - Name: "ECID",
382 - Type: ftInteger,
383 - Visualization: visValue,
384 - Sort: sortAsc,
385 - Sortable: true,
386 - Sticky: false,
387 - Summary: summaryCount,
388 - Filter: filterRange,
389 - Visible: true,
390 - ValueOptions: funcapi.ValueOptions{
391 - Transform: trNumber,
392 - },
393 - }.BuildColumn(),
394 - "is_victim": funcapi.Column{
395 - Index: deadlockIdxIsVictim,
396 - Name: "Victim",
397 - Type: ftString,
398 - Visualization: visPill,
399 - Sort: sortAsc,
400 - Sortable: true,
401 - Summary: summaryCount,
402 - Filter: filterMulti,
403 - Visible: true,
404 - ValueOptions: funcapi.ValueOptions{
405 - Transform: trNone,
406 - },
407 - }.BuildColumn(),
408 - "query_text": funcapi.Column{
409 - Index: deadlockIdxQueryText,
410 - Name: "Query",
411 - Type: ftString,
412 - Visualization: visValue,
413 - Sort: sortAsc,
414 - Sortable: false,
415 - Sticky: false,
416 - Summary: summaryCount,
417 - Filter: filterMulti,
418 - FullWidth: true,
419 - Wrap: true,
420 - Visible: true,
421 - ValueOptions: funcapi.ValueOptions{
422 - Transform: trNone,
423 - },
424 - }.BuildColumn(),
425 - "lock_mode": funcapi.Column{
426 - Index: deadlockIdxLockMode,
427 - Name: "Lock Mode",
428 - Type: ftString,
429 - Visualization: visValue,
430 - Sort: sortAsc,
431 - Sortable: true,
432 - Sticky: false,
433 - Summary: summaryCount,
434 - Filter: filterMulti,
435 - Visible: true,
436 - ValueOptions: funcapi.ValueOptions{
437 - Transform: trNone,
438 - },
439 - }.BuildColumn(),
440 - "lock_status": funcapi.Column{
441 - Index: deadlockIdxLockStatus,
442 - Name: "Lock Status",
443 - Type: ftString,
444 - Visualization: visPill,
445 - Sort: sortAsc,
446 - Sortable: true,
447 - Sticky: false,
448 - Summary: summaryCount,
449 - Filter: filterMulti,
450 - Visible: true,
451 - ValueOptions: funcapi.ValueOptions{
452 - Transform: trNone,
453 - },
454 - }.BuildColumn(),
455 - "wait_resource": funcapi.Column{
456 - Index: deadlockIdxWaitResource,
457 - Name: "Wait Resource",
458 - Type: ftString,
459 - Visualization: visValue,
460 - Sort: sortAsc,
461 - Sortable: false,
462 - Sticky: false,
463 - Summary: summaryCount,
464 - Filter: filterMulti,
465 - FullWidth: true,
466 - Wrap: true,
467 - Visible: true,
468 - ValueOptions: funcapi.ValueOptions{
469 - Transform: trNone,
470 - },
471 - }.BuildColumn(),
472 - "database": funcapi.Column{
473 - Index: deadlockIdxDatabase,
474 - Name: "Database",
475 - Type: ftString,
476 - Visualization: visValue,
477 - Sort: sortAsc,
478 - Sortable: true,
479 - Sticky: false,
480 - Summary: summaryCount,
481 - Filter: filterMulti,
482 - Visible: true,
483 - ValueOptions: funcapi.ValueOptions{
484 - Transform: trNone,
485 - },
486 - }.BuildColumn(),
487 - }
433 +type mssqlDeadlockProcessList struct {
434 + Processes []mssqlDeadlockProcess `xml:"process"`
435 +}
436 +
437 +type mssqlDeadlockProcess struct {
438 + ID string `xml:"id,attr"`
439 + SPID string `xml:"spid,attr"`
440 + ECID string `xml:"ecid,attr"`
441 + DBID string `xml:"dbid,attr"`
442 + LockMode string `xml:"lockMode,attr"`
443 + WaitResource string `xml:"waitresource,attr"`
444 + InputBuf string `xml:"inputbuf"`
445 +}
446 +
447 +type mssqlDeadlockResource struct {
448 + XMLName xml.Name
449 + DBID string `xml:"dbid,attr"`
450 + OwnerList mssqlDeadlockOwnerList `xml:"owner-list"`
451 + WaiterList mssqlDeadlockWaiterList `xml:"waiter-list"`
452 +}
453 +
454 +type mssqlDeadlockOwnerList struct {
455 + Owners []mssqlDeadlockResourceEntry `xml:"owner"`
456 +}
457 +
458 +type mssqlDeadlockWaiterList struct {
459 + Waiters []mssqlDeadlockResourceEntry `xml:"waiter"`
460 +}
461 +
462 +type mssqlDeadlockResourceEntry struct {
463 + ID string `xml:"id,attr"`
464 + Mode string `xml:"mode,attr"`
465 }
466
467 func parseDeadlockGraph(deadlockXML string, deadlockTime time.Time) mssqlDeadlockParseResult {
@@ -612,9 +589,9 @@ func parseDeadlockGraph(deadlockXML string, deadlockTime time.Time) mssqlDeadloc
589 return result
590 }
591
615 -func buildDeadlockRows(parseRes mssqlDeadlockParseResult, deadlockID string, dbNames map[int]string) [][]any {
592 +func buildDeadlockRows(parseRes mssqlDeadlockParseResult, deadlockID string, dbNames map[int]string) []deadlockRowData {
593 timestamp := parseRes.deadlockTime.UTC().Format(time.RFC3339Nano)
617 - rows := make([][]any, 0, len(parseRes.transactions))
594 + rows := make([]deadlockRowData, 0, len(parseRes.transactions))
595
596 for _, txn := range parseRes.transactions {
597 processID := strings.TrimSpace(txn.processID)
@@ -639,25 +616,24 @@ func buildDeadlockRows(parseRes mssqlDeadlockParseResult, deadlockID string, dbN
616 }
617
618 queryText := strmutil.TruncateText(strings.TrimSpace(txn.queryText), topQueriesMaxTextLength)
642 - lockMode := strings.TrimSpace(txn.lockMode)
619 + lockMode := formatLockMode(strings.TrimSpace(txn.lockMode))
620 lockStatus := strings.TrimSpace(txn.lockStatus)
621 waitResource := strmutil.TruncateText(strings.TrimSpace(txn.waitResource), topQueriesMaxTextLength)
622
646 - row := make([]any, deadlockColumnCount)
647 - row[deadlockIdxRowID] = fmt.Sprintf("%s:%s", deadlockID, processID)
648 - row[deadlockIdxDeadlockID] = deadlockID
649 - row[deadlockIdxTimestamp] = timestamp
650 - row[deadlockIdxProcessID] = processID
651 - row[deadlockIdxSpid] = spid
652 - row[deadlockIdxEcid] = ecid
653 - row[deadlockIdxIsVictim] = isVictim
654 - row[deadlockIdxQueryText] = queryText
655 - row[deadlockIdxLockMode] = lockMode
656 - row[deadlockIdxLockStatus] = lockStatus
657 - row[deadlockIdxWaitResource] = waitResource
658 - row[deadlockIdxDatabase] = database
659 -
660 - rows = append(rows, row)
623 + rows = append(rows, deadlockRowData{
624 + rowID: fmt.Sprintf("%s:%s", deadlockID, processID),
625 + deadlockID: deadlockID,
626 + timestamp: timestamp,
627 + processID: processID,
628 + spid: spid,
629 + ecid: ecid,
630 + isVictim: isVictim,
631 + queryText: queryText,
632 + lockMode: lockMode,
633 + lockStatus: lockStatus,
634 + waitResource: waitResource,
635 + database: database,
636 + })
637 }
638
639 return rows
@@ -715,3 +691,21 @@ func isDeadlockPermissionError(err error) bool {
691 func deadlockPermissionMessage() string {
692 return "deadlock info requires VIEW SERVER STATE permission. Grant with: GRANT VIEW SERVER STATE TO [netdata_user];"
693 }
694 +
695 +func formatLockMode(mode string) string {
696 + names := map[string]string{
697 + "X": "Exclusive",
698 + "S": "Shared",
699 + "U": "Update",
700 + "IX": "Intent Exclusive",
701 + "IS": "Intent Shared",
702 + "SIX": "Shared Intent Exclusive",
703 + "Sch-S": "Schema Stability",
704 + "Sch-M": "Schema Modification",
705 + "BU": "Bulk Update",
706 + }
707 + if name, ok := names[mode]; ok {
708 + return fmt.Sprintf("%s (%s)", name, mode)
709 + }
710 + return mode
711 +}
src/go/plugin/go.d/collector/mssql/func_deadlock_info_test.go renamed
+28 -17
@@ -15,6 +15,11 @@ import (
15 "github.com/stretchr/testify/require"
16 )
17
18 +func newTestDeadlockHandler(c *Collector) *funcDeadlockInfo {
19 + r := &funcRouter{collector: c}
20 + return &funcDeadlockInfo{router: r}
21 +}
22 +
23 func TestConfig_GetDeadlockInfoFunctionEnabled(t *testing.T) {
24 tests := []struct {
25 name string
@@ -150,9 +155,9 @@ func TestParseDeadlockGraph_ThreeWayDeadlock(t *testing.T) {
155
156 victimCount := 0
157 for _, row := range rows {
153 - assert.Equal(t, deadlockID, row[deadlockIdxDeadlockID])
154 - assert.True(t, strings.HasPrefix(row[deadlockIdxRowID].(string), deadlockID+":"))
155 - if row[deadlockIdxIsVictim] == "true" {
158 + assert.Equal(t, deadlockID, row.deadlockID)
159 + assert.True(t, strings.HasPrefix(row.rowID, deadlockID+":"))
160 + if row.isVictim == "true" {
161 victimCount++
162 }
163 }
@@ -197,15 +202,16 @@ func TestCollectDeadlockInfo_ParseError(t *testing.T) {
202 deadlockTime := time.Date(2026, time.January, 25, 12, 34, 56, 0, time.UTC)
203 deadlockRows := sqlmock.NewRows([]string{"deadlock_time", "deadlock_xml"}).
204 AddRow(deadlockTime, "<deadlock><broken>")
200 - mock.ExpectQuery("WITH xevents").WillReturnRows(deadlockRows)
205 + mock.ExpectQuery("fn_xe_file_target_read_file").WillReturnRows(deadlockRows)
206
207 dbNameRows := sqlmock.NewRows([]string{"database_id", "name"})
208 mock.ExpectQuery("SELECT\\s+database_id").WillReturnRows(dbNameRows)
209
210 c := New()
211 c.db = db
212 + handler := newTestDeadlockHandler(c)
213
208 - resp := c.collectDeadlockInfo(context.Background())
214 + resp := handler.collectData(context.Background())
215 require.Equal(t, deadlockParseErrorStatus, resp.Status)
216 assert.Contains(t, strings.ToLower(resp.Message), "could not be parsed")
217 require.NoError(t, mock.ExpectationsWereMet())
@@ -216,13 +222,14 @@ func TestCollectDeadlockInfo_QueryError(t *testing.T) {
222 require.NoError(t, err)
223 defer db.Close()
224
219 - mock.ExpectQuery("WITH xevents").
225 + mock.ExpectQuery("fn_xe_file_target_read_file").
226 WillReturnError(errors.New("boom"))
227
228 c := New()
229 c.db = db
230 + handler := newTestDeadlockHandler(c)
231
225 - resp := c.collectDeadlockInfo(context.Background())
232 + resp := handler.collectData(context.Background())
233 require.Equal(t, 500, resp.Status)
234 assert.Contains(t, strings.ToLower(resp.Message), "deadlock query failed")
235 require.NoError(t, mock.ExpectationsWereMet())
@@ -240,9 +247,9 @@ func TestBuildDeadlockRows(t *testing.T) {
247 require.Len(t, rows, 2)
248
249 row := rows[0]
243 - assert.Equal(t, deadlockID, row[deadlockIdxDeadlockID])
244 - assert.Equal(t, deadlockID+":"+row[deadlockIdxProcessID].(string), row[deadlockIdxRowID])
245 - assert.Equal(t, "netdata", row[deadlockIdxDatabase])
250 + assert.Equal(t, deadlockID, row.deadlockID)
251 + assert.Equal(t, deadlockID+":"+row.processID, row.rowID)
252 + assert.Equal(t, "netdata", row.database)
253 }
254
255 func TestDeadlockPermissionErrorDetection(t *testing.T) {
@@ -255,13 +262,14 @@ func TestCollectDeadlockInfo_PermissionDenied(t *testing.T) {
262 require.NoError(t, err)
263 defer db.Close()
264
258 - mock.ExpectQuery("WITH xevents").
265 + mock.ExpectQuery("fn_xe_file_target_read_file").
266 WillReturnError(mssqlDriver.Error{Number: 297, Message: "VIEW SERVER STATE permission was denied"})
267
268 c := New()
269 c.db = db
270 + handler := newTestDeadlockHandler(c)
271
264 - resp := c.collectDeadlockInfo(context.Background())
272 + resp := handler.collectData(context.Background())
273 require.Equal(t, 403, resp.Status)
274 assert.Contains(t, strings.ToLower(resp.Message), "view server state")
275 require.NoError(t, mock.ExpectationsWereMet())
@@ -270,8 +278,9 @@ func TestCollectDeadlockInfo_PermissionDenied(t *testing.T) {
278 func TestCollectDeadlockInfo_Disabled(t *testing.T) {
279 c := New()
280 c.Config.DeadlockInfoFunctionEnabled = boolPtr(false)
281 + handler := newTestDeadlockHandler(c)
282
274 - resp := c.collectDeadlockInfo(context.Background())
283 + resp := handler.collectData(context.Background())
284 require.Equal(t, 503, resp.Status)
285 assert.Contains(t, strings.ToLower(resp.Message), "disabled")
286 }
@@ -281,13 +290,14 @@ func TestCollectDeadlockInfo_Timeout(t *testing.T) {
290 require.NoError(t, err)
291 defer db.Close()
292
284 - mock.ExpectQuery("WITH xevents").
293 + mock.ExpectQuery("fn_xe_file_target_read_file").
294 WillReturnError(context.DeadlineExceeded)
295
296 c := New()
297 c.db = db
298 + handler := newTestDeadlockHandler(c)
299
290 - resp := c.collectDeadlockInfo(context.Background())
300 + resp := handler.collectData(context.Background())
301 require.Equal(t, 504, resp.Status)
302 assert.Contains(t, strings.ToLower(resp.Message), "timed out")
303 require.NoError(t, mock.ExpectationsWereMet())
@@ -300,7 +310,7 @@ func TestCollectDeadlockInfo_Success(t *testing.T) {
310
311 now := time.Date(2026, time.January, 25, 12, 0, 0, 0, time.UTC)
312
303 - mock.ExpectQuery("WITH xevents").
313 + mock.ExpectQuery("fn_xe_file_target_read_file").
314 WillReturnRows(
315 sqlmock.NewRows([]string{"deadlock_time", "deadlock_xml"}).
316 AddRow(now, sampleDeadlockGraph),
@@ -314,8 +324,9 @@ func TestCollectDeadlockInfo_Success(t *testing.T) {
324
325 c := New()
326 c.db = db
327 + handler := newTestDeadlockHandler(c)
328
318 - resp := c.collectDeadlockInfo(context.Background())
329 + resp := handler.collectData(context.Background())
330 require.Equal(t, 200, resp.Status)
331 require.NotEmpty(t, resp.Data)
332 require.NoError(t, mock.ExpectationsWereMet())
src/go/plugin/go.d/collector/mssql/func_error_info.go renamed
+141 -100
@@ -22,6 +22,110 @@ const (
22
23 const errorInfoMethodID = "error-info"
24
25 +type mssqlErrorRow struct {
26 + Time time.Time
27 + ErrorNumber *int64
28 + ErrorState *int64
29 + Message string
30 + Query string
31 + QueryHash string
32 +}
33 +
34 +type mssqlPlanOps struct {
35 + HashMatch int64
36 + MergeJoin int64
37 + NestedLoops int64
38 + Sorts int64
39 +}
40 +
41 +// errorInfoColumn defines a column for the error-info function.
42 +type errorInfoColumn struct {
43 + funcapi.ColumnMeta
44 + Value func(*mssqlErrorRow) any
45 +}
46 +
47 +func errorInfoColumnSet(cols []errorInfoColumn) funcapi.ColumnSet[errorInfoColumn] {
48 + return funcapi.Columns(cols, func(c errorInfoColumn) funcapi.ColumnMeta { return c.ColumnMeta })
49 +}
50 +
51 +var errorInfoColumns = []errorInfoColumn{
52 + {
53 + ColumnMeta: funcapi.ColumnMeta{
54 + Name: "timestamp",
55 + Tooltip: "When the error occurred",
56 + Type: funcapi.FieldTypeTimestamp,
57 + Sortable: true,
58 + Visible: true,
59 + Transform: funcapi.FieldTransformDatetime,
60 + },
61 + Value: func(r *mssqlErrorRow) any { return r.Time },
62 + },
63 + {
64 + ColumnMeta: funcapi.ColumnMeta{
65 + Name: "errorNumber",
66 + Tooltip: "SQL Server error number",
67 + Type: funcapi.FieldTypeInteger,
68 + Sortable: true,
69 + Visible: true,
70 + Transform: funcapi.FieldTransformNumber,
71 + },
72 + Value: func(r *mssqlErrorRow) any {
73 + if r.ErrorNumber == nil {
74 + return nil
75 + }
76 + return *r.ErrorNumber
77 + },
78 + },
79 + {
80 + ColumnMeta: funcapi.ColumnMeta{
81 + Name: "errorState",
82 + Tooltip: "Diagnostic code indicating where the error was raised (1-255)",
83 + Type: funcapi.FieldTypeInteger,
84 + Sortable: true,
85 + Visible: true,
86 + Transform: funcapi.FieldTransformNumber,
87 + },
88 + Value: func(r *mssqlErrorRow) any {
89 + if r.ErrorState == nil {
90 + return nil
91 + }
92 + return *r.ErrorState
93 + },
94 + },
95 + {
96 + ColumnMeta: funcapi.ColumnMeta{
97 + Name: "errorMessage",
98 + Tooltip: "The error message text",
99 + Type: funcapi.FieldTypeString,
100 + Sortable: false,
101 + FullWidth: true,
102 + Visible: true,
103 + },
104 + Value: func(r *mssqlErrorRow) any { return r.Message },
105 + },
106 + {
107 + ColumnMeta: funcapi.ColumnMeta{
108 + Name: "query",
109 + Tooltip: "The SQL statement that caused the error",
110 + Type: funcapi.FieldTypeString,
111 + Sortable: false,
112 + FullWidth: true,
113 + Visible: true,
114 + },
115 + Value: func(r *mssqlErrorRow) any { return r.Query },
116 + },
117 + {
118 + ColumnMeta: funcapi.ColumnMeta{
119 + Name: "queryHash",
120 + Tooltip: "Hash of the query for grouping similar statements",
121 + Type: funcapi.FieldTypeString,
122 + Sortable: true,
123 + Visible: false,
124 + },
125 + Value: func(r *mssqlErrorRow) any { return r.QueryHash },
126 + },
127 +}
128 +
129 func errorInfoMethodConfig() funcapi.MethodConfig {
130 return funcapi.MethodConfig{
131 ID: errorInfoMethodID,
@@ -60,36 +164,13 @@ func (f *funcErrorInfo) Handle(ctx context.Context, method string, params funcap
164 }
165 f.router.collector.db = db
166 }
63 - return f.router.collector.collectErrorInfo(ctx)
167 + return f.collectData(ctx)
168 }
169
170 func (f *funcErrorInfo) Cleanup(ctx context.Context) {}
171
68 -type mssqlErrorRow struct {
69 - Time time.Time
70 - ErrorNumber *int64
71 - ErrorState *int64
72 - Message string
73 - Query string
74 - QueryHash string
75 -}
76 -
77 -type mssqlPlanOps struct {
78 - HashMatch int64
79 - MergeJoin int64
80 - NestedLoops int64
81 - Sorts int64
82 -}
83 -
84 -func (c *Collector) errorInfoParams(context.Context) ([]funcapi.ParamConfig, error) {
85 - if !c.Config.GetErrorInfoFunctionEnabled() {
86 - return nil, fmt.Errorf("error-info function disabled in configuration")
87 - }
88 - return []funcapi.ParamConfig{}, nil
89 -}
90 -
91 -func (c *Collector) collectErrorInfo(ctx context.Context) *funcapi.FunctionResponse {
92 - if !c.Config.GetErrorInfoFunctionEnabled() {
172 +func (f *funcErrorInfo) collectData(ctx context.Context) *funcapi.FunctionResponse {
173 + if !f.router.collector.Config.GetErrorInfoFunctionEnabled() {
174 return &funcapi.FunctionResponse{
175 Status: 503,
176 Message: "error-info not enabled: function disabled in configuration. " +
@@ -97,98 +178,42 @@ func (c *Collector) collectErrorInfo(ctx context.Context) *funcapi.FunctionRespo
178 }
179 }
180
100 - sessionName := c.Config.GetErrorInfoSessionName()
101 - status, rows, err := c.fetchMSSQLErrorRows(ctx, sessionName, c.TopQueriesLimit)
181 + sessionName := f.router.collector.Config.GetErrorInfoSessionName()
182 + status, rows, err := f.router.collector.fetchMSSQLErrorRows(ctx, sessionName, f.router.collector.TopQueriesLimit)
183 if err != nil {
184 if isDeadlockPermissionError(err) {
185 return &funcapi.FunctionResponse{Status: 403, Message: errorInfoPermissionMessage()}
186 }
187 if status == mssqlErrorAttrNotEnabled {
107 - return &funcapi.FunctionResponse{Status: 503, Message: "error-info not enabled: Extended Events session not found or ring_buffer target missing"}
188 + targetName := "event_file"
189 + if f.router.collector.Config.GetErrorInfoUseRingBuffer() {
190 + targetName = "ring_buffer"
191 + }
192 + return &funcapi.FunctionResponse{Status: 503, Message: fmt.Sprintf("error-info not enabled: Extended Events session not found or %s target missing", targetName)}
193 }
194 return &funcapi.FunctionResponse{Status: 500, Message: fmt.Sprintf("error-info query failed: %v", err)}
195 }
196
197 data := make([][]any, 0, len(rows))
113 - for _, row := range rows {
114 - var errNo any
115 - var errState any
116 - if row.ErrorNumber != nil {
117 - errNo = *row.ErrorNumber
198 + for i := range rows {
199 + row := make([]any, len(errorInfoColumns))
200 + for j, col := range errorInfoColumns {
201 + row[j] = col.Value(&rows[i])
202 }
119 - if row.ErrorState != nil {
120 - errState = *row.ErrorState
121 - }
122 - data = append(data, []any{
123 - row.Time,
124 - errNo,
125 - errState,
126 - row.Message,
127 - row.Query,
128 - row.QueryHash,
129 - })
203 + data = append(data, row)
204 }
205
206 + cs := errorInfoColumnSet(errorInfoColumns)
207 +
208 return &funcapi.FunctionResponse{
209 Status: 200,
210 Help: "Recent SQL errors from Extended Events error_reported",
135 - Columns: buildMSSQLErrorInfoColumns(),
211 + Columns: cs.BuildColumns(),
212 Data: data,
213 DefaultSortColumn: "timestamp",
214 }
215 }
216
141 -func buildMSSQLErrorInfoColumns() map[string]any {
142 - columns := map[string]any{
143 - "timestamp": funcapi.Column{
144 - Index: 0,
145 - Name: "Timestamp",
146 - Type: funcapi.FieldTypeTimestamp,
147 - Sortable: true,
148 - ValueOptions: funcapi.ValueOptions{Transform: funcapi.FieldTransformDatetime},
149 - }.BuildColumn(),
150 - "errorNumber": funcapi.Column{
151 - Index: 1,
152 - Name: "Error Number",
153 - Type: funcapi.FieldTypeInteger,
154 - Sortable: true,
155 - ValueOptions: funcapi.ValueOptions{Transform: funcapi.FieldTransformNumber},
156 - }.BuildColumn(),
157 - "errorState": funcapi.Column{
158 - Index: 2,
159 - Name: "Error State",
160 - Type: funcapi.FieldTypeInteger,
161 - Sortable: true,
162 - ValueOptions: funcapi.ValueOptions{Transform: funcapi.FieldTransformNumber},
163 - }.BuildColumn(),
164 - "errorMessage": funcapi.Column{
165 - Index: 3,
166 - Name: "Error Message",
167 - Type: funcapi.FieldTypeString,
168 - Sortable: false,
169 - FullWidth: true,
170 - ValueOptions: funcapi.ValueOptions{Transform: funcapi.FieldTransformNone},
171 - }.BuildColumn(),
172 - "query": funcapi.Column{
173 - Index: 4,
174 - Name: "Query",
175 - Type: funcapi.FieldTypeString,
176 - Sortable: false,
177 - FullWidth: true,
178 - ValueOptions: funcapi.ValueOptions{Transform: funcapi.FieldTransformNone},
179 - }.BuildColumn(),
180 - "queryHash": funcapi.Column{
181 - Index: 5,
182 - Name: "Query Hash",
183 - Type: funcapi.FieldTypeString,
184 - Sortable: true,
185 - Visible: false,
186 - ValueOptions: funcapi.ValueOptions{Transform: funcapi.FieldTransformNone},
187 - }.BuildColumn(),
188 - }
189 - return columns
190 -}
191 -
217 func errorInfoPermissionMessage() string {
218 return "error-info requires VIEW SERVER STATE permission. Grant with: GRANT VIEW SERVER STATE TO [netdata_user];"
219 }
@@ -198,7 +223,7 @@ func mssqlErrorAttributionColumns() []topQueriesColumn {
223 {
224 ColumnMeta: funcapi.ColumnMeta{
225 Name: "errorAttribution",
201 - Tooltip: "Error Attribution",
226 + Tooltip: "Source of error data (enabled, not_enabled, no_data)",
227 Type: funcapi.FieldTypeString,
228 Visible: true,
229 Transform: funcapi.FieldTransformNone,
@@ -331,6 +356,12 @@ func nullableString(value string) any {
356 return value
357 }
358
359 +// TODO: Refactor error data access into a shared mssqlErrorData type.
360 +// Currently these methods live on Collector because they're used by both:
361 +// - funcErrorInfo (for error-info function)
362 +// - funcTopQueries (for error attribution columns)
363 +// A cleaner design would be a mssqlErrorData type on funcRouter that both handlers use.
364 +
365 func (c *Collector) collectMSSQLErrorDetails(ctx context.Context) (string, map[string]mssqlErrorRow) {
366 status, rows, err := c.fetchMSSQLErrorRows(ctx, c.Config.GetErrorInfoSessionName(), c.TopQueriesLimit)
367 if err != nil {
@@ -505,7 +536,12 @@ func (c *Collector) fetchMSSQLErrorRows(ctx context.Context, sessionName string,
536 qctx, cancel := context.WithTimeout(ctx, c.Timeout.Duration())
537 defer cancel()
538
508 - rows, err := c.db.QueryContext(qctx, queryMSSQLErrorInfo, sql.Named("sessionName", sessionName), sql.Named("limit", limit))
539 + query := queryMSSQLErrorInfoEventFile
540 + if c.Config.GetErrorInfoUseRingBuffer() {
541 + query = queryMSSQLErrorInfoRingBuffer
542 + }
543 +
544 + rows, err := c.db.QueryContext(qctx, query, sql.Named("sessionName", sessionName), sql.Named("limit", limit))
545 if err != nil {
546 return mssqlErrorAttrNotSupported, nil, err
547 }
@@ -563,7 +599,12 @@ func (c *Collector) mssqlErrorSessionAvailable(ctx context.Context, sessionName
599 return false, nil
600 }
601
566 - err = c.db.QueryRowContext(qctx, queryMSSQLErrorSessionHasRingBuffer, sql.Named("sessionName", sessionName)).Scan(&count)
602 + targetQuery := queryMSSQLErrorSessionHasEventFile
603 + if c.Config.GetErrorInfoUseRingBuffer() {
604 + targetQuery = queryMSSQLErrorSessionHasRingBuffer
605 + }
606 +
607 + err = c.db.QueryRowContext(qctx, targetQuery, sql.Named("sessionName", sessionName)).Scan(&count)
608 if err != nil {
609 return false, err
610 }
src/go/plugin/go.d/collector/mssql/metadata.yaml
+36 -5
@@ -619,7 +619,7 @@ modules:
619 - id: deadlock-info
620 name: Deadlock Info
621 description: |
622 - Retrieves the most recent deadlock event from SQL Server's `system_health` Extended Events ring buffer (`xml_deadlock_report`).
622 + Retrieves the most recent deadlock event from SQL Server's `system_health` Extended Events session (`xml_deadlock_report`).
623
624 The deadlock graph XML is parsed to attribute the deadlock to the participating processes and their query text, lock mode, lock status, and wait resource.
625
@@ -645,7 +645,7 @@ modules:
645 - name: Timestamp
646 type: timestamp
647 unit: ""
648 - description: "Timestamp of the deadlock event from the ring buffer when available; otherwise the function execution time."
648 + description: "Timestamp of the deadlock event from Extended Events when available; otherwise the function execution time."
649 - name: Process ID
650 type: string
651 unit: ""
@@ -695,7 +695,7 @@ modules:
695 Retrieves recent SQL errors from a user-managed Extended Events session that captures `sqlserver.error_reported`
696 with both the `sql_text` and `query_hash` actions.
697
698 - The session must be created by an administrator and include a `ring_buffer` target. Netdata reads the ring buffer
698 + The session must be created by an administrator and include an `event_file` target. Netdata reads the event file
699 and returns recent error events with error number, message, and SQL text. The `query_hash` action is required for
700 reliable mapping into `top-queries` (query text fallback is best-effort).
701
@@ -733,11 +733,42 @@ modules:
733 visibility: hidden
734 description: "Query hash captured with the error event (used for mapping into top-queries)."
735 performance: |
736 - Executes on-demand queries against the configured Extended Events ring buffer:<br/>• Not part of regular metric collection<br/>• Overhead is limited to function execution time and XML parsing
736 + Executes on-demand queries against the configured Extended Events event file:<br/>• Not part of regular metric collection<br/>• Overhead is limited to function execution time
737 security: |
738 Error messages and query text may include unmasked literal values including sensitive data (PII/secrets):<br/>• Restrict dashboard access to authorized personnel only
739 + prerequisites:
740 + list:
741 + - title: Create Extended Events session for error capture
742 + description: |
743 + Create an Extended Events session that captures `sqlserver.error_reported` with `sql_text` and `query_hash` actions:
744 +
745 + ```sql
746 + -- Create the Extended Events session with event_file target
747 + CREATE EVENT SESSION [netdata_errors] ON SERVER
748 + ADD EVENT sqlserver.error_reported(
749 + ACTION(sqlserver.sql_text, sqlserver.query_hash)
750 + )
751 + ADD TARGET package0.event_file(SET filename=N'netdata_errors');
752 + GO
753 +
754 + -- Start the session
755 + ALTER EVENT SESSION [netdata_errors] ON SERVER STATE = START;
756 + GO
757 +
758 + -- Grant required permission
759 + GRANT VIEW SERVER STATE TO [netdata_user];
760 + ```
761 +
762 + If you use a different session name, set it in the collector config:
763 +
764 + ```yaml
765 + jobs:
766 + - name: local
767 + dsn: "sqlserver://user:pass@localhost:1433"
768 + error_info_session_name: your_session_name
769 + ```
770 availability: |
740 - Available when:<br/>• The collector has successfully connected to SQL Server<br/>• `error_info_function_enabled` is true<br/>• The Extended Events session exists and has a ring_buffer target<br/>• The account has `VIEW SERVER STATE` permission<br/>• Returns HTTP 200 with empty data when no errors are found<br/>• Returns HTTP 403 when permission is missing<br/>• Returns HTTP 500 if the query fails<br/>• Returns HTTP 503 if the session is not enabled or the function is disabled<br/>• Returns HTTP 504 if the query times out
771 + Available when:<br/>• The collector has successfully connected to SQL Server<br/>• `error_info_function_enabled` is true<br/>• The Extended Events session exists and has an event_file target<br/>• The account has `VIEW SERVER STATE` permission<br/>• Returns HTTP 200 with empty data when no errors are found<br/>• Returns HTTP 403 when permission is missing<br/>• Returns HTTP 500 if the query fails<br/>• Returns HTTP 503 if the session is not enabled or the function is disabled<br/>• Returns HTTP 504 if the query times out
772 require_cloud: true
773 metrics:
774 folding:
src/go/plugin/go.d/collector/mssql/queries.go
+40 -4
@@ -201,9 +201,21 @@ WHERE object_name LIKE '%SQL Errors%'
201 AND counter_name = 'Errors/sec';
202 `
203
204 -// querySystemHealthLatestDeadlock retrieves the latest xml_deadlock_report event
204 +// querySystemHealthLatestDeadlockEventFile retrieves the latest xml_deadlock_report event
205 +// from the system_health Extended Events file target.
206 +const querySystemHealthLatestDeadlockEventFile = `
207 +SELECT TOP (1)
208 + timestamp_utc AS deadlock_time,
209 + CONVERT(nvarchar(max), CAST(event_data AS XML).query('(event/data[@name="xml_report"]/value/deadlock)[1]')) AS deadlock_xml
210 +FROM sys.fn_xe_file_target_read_file('system_health*.xel', NULL, NULL, NULL)
211 +WHERE object_name = 'xml_deadlock_report'
212 +ORDER BY timestamp_utc DESC;
213 +`
214 +
215 +// querySystemHealthLatestDeadlockRingBuffer retrieves the latest xml_deadlock_report event
216 // from the system_health Extended Events ring buffer.
206 -const querySystemHealthLatestDeadlock = `
217 +// Note: This can be slow with large buffers due to XML parsing overhead.
218 +const querySystemHealthLatestDeadlockRingBuffer = `
219 WITH xevents AS (
220 SELECT CAST(xet.target_data AS XML) AS target_data
221 FROM sys.dm_xe_session_targets AS xet
@@ -238,6 +250,15 @@ FROM sys.dm_xe_sessions
250 WHERE name = @sessionName;
251 `
252
253 +// queryMSSQLErrorSessionHasEventFile verifies that the session has an event_file target.
254 +const queryMSSQLErrorSessionHasEventFile = `
255 +SELECT COUNT(*)
256 +FROM sys.dm_xe_session_targets AS xet
257 +JOIN sys.dm_xe_sessions AS xs ON xs.address = xet.event_session_address
258 +WHERE xs.name = @sessionName
259 + AND xet.target_name = 'event_file';
260 +`
261 +
262 // queryMSSQLErrorSessionHasRingBuffer verifies that the session has a ring_buffer target.
263 const queryMSSQLErrorSessionHasRingBuffer = `
264 SELECT COUNT(*)
@@ -247,8 +268,23 @@ WHERE xs.name = @sessionName
268 AND xet.target_name = 'ring_buffer';
269 `
270
250 -// queryMSSQLErrorInfo reads recent error_reported events from the ring_buffer target.
251 -const queryMSSQLErrorInfo = `
271 +// queryMSSQLErrorInfoEventFile reads recent error_reported events from the event_file target.
272 +const queryMSSQLErrorInfoEventFile = `
273 +SELECT TOP (@limit)
274 + timestamp_utc AS event_time,
275 + CAST(event_data AS XML).value('(event/data[@name="error_number"]/value)[1]', 'int') AS error_number,
276 + CAST(event_data AS XML).value('(event/data[@name="state"]/value)[1]', 'int') AS error_state,
277 + CAST(event_data AS XML).value('(event/data[@name="message"]/value)[1]', 'nvarchar(max)') AS message,
278 + CAST(event_data AS XML).value('(event/action[@name="sql_text"]/value)[1]', 'nvarchar(max)') AS sql_text,
279 + CONVERT(VARCHAR(64), CAST(event_data AS XML).value('(event/action[@name="query_hash"]/value)[1]', 'varbinary(8)'), 1) AS query_hash
280 +FROM sys.fn_xe_file_target_read_file(@sessionName + N'*.xel', NULL, NULL, NULL)
281 +WHERE object_name = 'error_reported'
282 +ORDER BY event_time DESC;
283 +`
284 +
285 +// queryMSSQLErrorInfoRingBuffer reads recent error_reported events from the ring_buffer target.
286 +// Note: This can be slow with large buffers due to XML parsing overhead.
287 +const queryMSSQLErrorInfoRingBuffer = `
288 WITH xevents AS (
289 SELECT CAST(xet.target_data AS XML) AS target_data
290 FROM sys.dm_xe_session_targets AS xet
src/go/plugin/go.d/collector/mysql/func_deadlock_info.go
+12 -12
@@ -77,7 +77,7 @@ var deadlockColumns = []deadlockColumn{
77 {
78 ColumnMeta: funcapi.ColumnMeta{
79 Name: "row_id",
80 - Tooltip: "Row ID",
80 + Tooltip: "Unique identifier for this row",
81 Type: funcapi.FieldTypeString,
82 Sort: funcapi.FieldSortAscending,
83 Sortable: true,
@@ -91,7 +91,7 @@ var deadlockColumns = []deadlockColumn{
91 {
92 ColumnMeta: funcapi.ColumnMeta{
93 Name: "timestamp",
94 - Tooltip: "Timestamp",
94 + Tooltip: "When the deadlock occurred",
95 Type: funcapi.FieldTypeTimestamp,
96 Sort: funcapi.FieldSortDescending,
97 Sortable: true,
@@ -105,7 +105,7 @@ var deadlockColumns = []deadlockColumn{
105 {
106 ColumnMeta: funcapi.ColumnMeta{
107 Name: "is_victim",
108 - Tooltip: "Victim",
108 + Tooltip: "Whether this transaction was rolled back to resolve the deadlock",
109 Type: funcapi.FieldTypeString,
110 Visualization: funcapi.FieldVisualPill,
111 Sort: funcapi.FieldSortAscending,
@@ -119,7 +119,7 @@ var deadlockColumns = []deadlockColumn{
119 {
120 ColumnMeta: funcapi.ColumnMeta{
121 Name: "query_text",
122 - Tooltip: "Query",
122 + Tooltip: "The SQL statement being executed",
123 Type: funcapi.FieldTypeString,
124 Sort: funcapi.FieldSortAscending,
125 Sortable: false,
@@ -135,7 +135,7 @@ var deadlockColumns = []deadlockColumn{
135 {
136 ColumnMeta: funcapi.ColumnMeta{
137 Name: "database",
138 - Tooltip: "Database",
138 + Tooltip: "Database where the deadlock occurred",
139 Type: funcapi.FieldTypeString,
140 Sort: funcapi.FieldSortAscending,
141 Sortable: true,
@@ -148,7 +148,7 @@ var deadlockColumns = []deadlockColumn{
148 {
149 ColumnMeta: funcapi.ColumnMeta{
150 Name: "lock_mode",
151 - Tooltip: "Lock Mode",
151 + Tooltip: "Type of lock (S=Shared, X=Exclusive, IS/IX=Intent locks)",
152 Type: funcapi.FieldTypeString,
153 Sort: funcapi.FieldSortAscending,
154 Sortable: true,
@@ -161,7 +161,7 @@ var deadlockColumns = []deadlockColumn{
161 {
162 ColumnMeta: funcapi.ColumnMeta{
163 Name: "lock_status",
164 - Tooltip: "Lock Status",
164 + Tooltip: "Whether the lock was granted or still waiting",
165 Type: funcapi.FieldTypeString,
166 Visualization: funcapi.FieldVisualPill,
167 Sort: funcapi.FieldSortAscending,
@@ -175,7 +175,7 @@ var deadlockColumns = []deadlockColumn{
175 {
176 ColumnMeta: funcapi.ColumnMeta{
177 Name: "wait_resource",
178 - Tooltip: "Wait Resource",
178 + Tooltip: "The resource this transaction was waiting to acquire",
179 Type: funcapi.FieldTypeString,
180 Sort: funcapi.FieldSortAscending,
181 Sortable: false,
@@ -188,7 +188,7 @@ var deadlockColumns = []deadlockColumn{
188 {
189 ColumnMeta: funcapi.ColumnMeta{
190 Name: "spid",
191 - Tooltip: "Connection ID",
191 + Tooltip: "MySQL thread/connection ID",
192 Type: funcapi.FieldTypeInteger,
193 Sort: funcapi.FieldSortAscending,
194 Sortable: true,
@@ -202,7 +202,7 @@ var deadlockColumns = []deadlockColumn{
202 {
203 ColumnMeta: funcapi.ColumnMeta{
204 Name: "process_id",
205 - Tooltip: "Process ID",
205 + Tooltip: "Transaction identifier from InnoDB",
206 Type: funcapi.FieldTypeString,
207 Sort: funcapi.FieldSortAscending,
208 Sortable: true,
@@ -215,7 +215,7 @@ var deadlockColumns = []deadlockColumn{
215 {
216 ColumnMeta: funcapi.ColumnMeta{
217 Name: "deadlock_id",
218 - Tooltip: "Deadlock ID",
218 + Tooltip: "Unique identifier for this deadlock event",
219 Type: funcapi.FieldTypeString,
220 Sort: funcapi.FieldSortAscending,
221 Sortable: true,
@@ -228,7 +228,7 @@ var deadlockColumns = []deadlockColumn{
228 {
229 ColumnMeta: funcapi.ColumnMeta{
230 Name: "ecid",
231 - Tooltip: "ECID",
231 + Tooltip: "Execution Context ID (SQL Server concept, not used in MySQL)",
232 Type: funcapi.FieldTypeInteger,
233 Sort: funcapi.FieldSortAscending,
234 Sortable: true,
src/go/plugin/go.d/collector/mysql/func_error_info.go
+7 -7
@@ -37,7 +37,7 @@ var errorInfoColumns = []errorInfoColumn{
37 {
38 ColumnMeta: funcapi.ColumnMeta{
39 Name: "digest",
40 - Tooltip: "Digest",
40 + Tooltip: "Normalized hash of the query for grouping similar statements",
41 Type: funcapi.FieldTypeString,
42 Sortable: true,
43 Visible: false,
@@ -48,7 +48,7 @@ var errorInfoColumns = []errorInfoColumn{
48 {
49 ColumnMeta: funcapi.ColumnMeta{
50 Name: "query",
51 - Tooltip: "Query",
51 + Tooltip: "The SQL statement that caused the error",
52 Type: funcapi.FieldTypeString,
53 Sortable: true,
54 Visible: true,
@@ -60,7 +60,7 @@ var errorInfoColumns = []errorInfoColumn{
60 {
61 ColumnMeta: funcapi.ColumnMeta{
62 Name: "schema",
63 - Tooltip: "Schema",
63 + Tooltip: "Database/schema where the error occurred",
64 Type: funcapi.FieldTypeString,
65 Sortable: true,
66 Visible: true,
@@ -70,7 +70,7 @@ var errorInfoColumns = []errorInfoColumn{
70 {
71 ColumnMeta: funcapi.ColumnMeta{
72 Name: "errorNumber",
73 - Tooltip: "Error Number",
73 + Tooltip: "MySQL error number (MYSQL_ERRNO)",
74 Type: funcapi.FieldTypeInteger,
75 Sortable: true,
76 Visible: true,
@@ -86,7 +86,7 @@ var errorInfoColumns = []errorInfoColumn{
86 {
87 ColumnMeta: funcapi.ColumnMeta{
88 Name: "sqlState",
89 - Tooltip: "SQL State",
89 + Tooltip: "5-character SQLSTATE error code",
90 Type: funcapi.FieldTypeString,
91 Sortable: true,
92 Visible: true,
@@ -96,7 +96,7 @@ var errorInfoColumns = []errorInfoColumn{
96 {
97 ColumnMeta: funcapi.ColumnMeta{
98 Name: "errorMessage",
99 - Tooltip: "Error Message",
99 + Tooltip: "The error message text",
100 Type: funcapi.FieldTypeString,
101 Sortable: false,
102 Visible: true,
@@ -267,7 +267,7 @@ func mysqlErrorAttributionColumns() []topQueriesColumn {
267 {
268 ColumnMeta: funcapi.ColumnMeta{
269 Name: "errorAttribution",
270 - Tooltip: "Error Attribution",
270 + Tooltip: "Source of error data (enabled, not_enabled, no_data)",
271 Type: funcapi.FieldTypeString,
272 Visible: true,
273 Transform: funcapi.FieldTransformNone,