master
go 361 lines 14.3 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package yugabytedb
4
5 import (
6 "context"
7 "database/sql"
8 "errors"
9 "fmt"
10 "strings"
11
12 "github.com/netdata/netdata/go/plugins/pkg/funcapi"
13 "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/sqlquery"
14 "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/strmutil"
15 )
16
17 const (
18 topQueriesMethodID = "top-queries"
19 topQueriesMaxTextLength = 4096
20 )
21
22 func topQueriesMethodConfig() funcapi.MethodConfig {
23 return funcapi.MethodConfig{
24 ID: topQueriesMethodID,
25 Name: "Top Queries",
26 UpdateEvery: 10,
27 Help: "Top SQL queries from pg_stat_statements. WARNING: Query text may contain unmasked literals (potential PII).",
28 RequireCloud: true,
29 RequiredParams: []funcapi.ParamConfig{funcapi.BuildSortParam(topQueriesColumns)},
30 }
31 }
32
33 // topQueriesColumn embeds funcapi.ColumnMeta and adds YugabyteDB-specific fields.
34 type topQueriesColumn struct {
35 funcapi.ColumnMeta
36 SelectExpr string // SQL expression for SELECT clause
37 sortOpt bool // whether this column appears as a sort option
38 sortLbl string // label for sort option dropdown
39 defaultSort bool // default sort column
40 IsJoinColumn bool // column comes from a JOIN, not pg_stat_statements
41 }
42
43 var topQueriesColumns = []topQueriesColumn{
44 {ColumnMeta: funcapi.ColumnMeta{Name: "queryId", Tooltip: "Query ID", Type: funcapi.FieldTypeString, Visible: false, Sortable: true, Filter: funcapi.FieldFilterMultiselect, Transform: funcapi.FieldTransformText, UniqueKey: true, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount}, SelectExpr: "s.queryid::text"},
45 {ColumnMeta: funcapi.ColumnMeta{Name: "query", Tooltip: "Query", Type: funcapi.FieldTypeString, Visible: true, Sortable: false, Filter: funcapi.FieldFilterMultiselect, Transform: funcapi.FieldTransformText, Sticky: true, FullWidth: true, Wrap: true}, SelectExpr: "s.query"},
46 {ColumnMeta: funcapi.ColumnMeta{Name: "database", Tooltip: "Database", Type: funcapi.FieldTypeString, Visible: true, Sortable: true, Filter: funcapi.FieldFilterMultiselect, Transform: funcapi.FieldTransformText, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, GroupBy: &funcapi.GroupByOptions{IsDefault: true}}, SelectExpr: "d.datname", IsJoinColumn: true},
47 {ColumnMeta: funcapi.ColumnMeta{Name: "user", Tooltip: "User", Type: funcapi.FieldTypeString, Visible: true, Sortable: true, Filter: funcapi.FieldFilterMultiselect, Transform: funcapi.FieldTransformText, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, GroupBy: &funcapi.GroupByOptions{}}, SelectExpr: "u.usename", IsJoinColumn: true},
48
49 {ColumnMeta: funcapi.ColumnMeta{Name: "calls", Tooltip: "Calls", Type: funcapi.FieldTypeInteger, Visible: true, Sortable: true, Filter: funcapi.FieldFilterRange, Transform: funcapi.FieldTransformNumber, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummarySum, Chart: &funcapi.ChartOptions{Group: "Calls", Title: "Number of Calls", IsDefault: true}}, SelectExpr: "s.calls", sortOpt: true, sortLbl: "Top queries by Calls"},
50 {ColumnMeta: funcapi.ColumnMeta{Name: "totalTime", Tooltip: "Total Time", Type: funcapi.FieldTypeDuration, Units: "milliseconds", DecimalPoints: 2, Visible: true, Sortable: true, Filter: funcapi.FieldFilterRange, Transform: funcapi.FieldTransformDuration, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummarySum, Chart: &funcapi.ChartOptions{Group: "Time", Title: "Execution Time", IsDefault: true}}, SelectExpr: "s.total_time", sortOpt: true, defaultSort: true, sortLbl: "Top queries by Total Time"},
51 {ColumnMeta: funcapi.ColumnMeta{Name: "meanTime", Tooltip: "Mean Time", Type: funcapi.FieldTypeDuration, Units: "milliseconds", DecimalPoints: 2, Visible: true, Sortable: true, Filter: funcapi.FieldFilterRange, Transform: funcapi.FieldTransformDuration, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMean, Chart: &funcapi.ChartOptions{Group: "Time", Title: "Execution Time"}}, SelectExpr: "s.mean_time", sortOpt: true, sortLbl: "Top queries by Mean Time"},
52 {ColumnMeta: funcapi.ColumnMeta{Name: "minTime", Tooltip: "Min Time", Type: funcapi.FieldTypeDuration, Units: "milliseconds", DecimalPoints: 2, Visible: false, Sortable: true, Filter: funcapi.FieldFilterRange, Transform: funcapi.FieldTransformDuration, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMin, Chart: &funcapi.ChartOptions{Group: "Time", Title: "Execution Time"}}, SelectExpr: "s.min_time"},
53 {ColumnMeta: funcapi.ColumnMeta{Name: "maxTime", Tooltip: "Max Time", Type: funcapi.FieldTypeDuration, Units: "milliseconds", DecimalPoints: 2, Visible: false, Sortable: true, Filter: funcapi.FieldFilterRange, Transform: funcapi.FieldTransformDuration, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMax, Chart: &funcapi.ChartOptions{Group: "Time", Title: "Execution Time"}}, SelectExpr: "s.max_time", sortOpt: true, sortLbl: "Top queries by Max Time"},
54 {ColumnMeta: funcapi.ColumnMeta{Name: "rows", Tooltip: "Rows", Type: funcapi.FieldTypeInteger, Visible: true, Sortable: true, Filter: funcapi.FieldFilterRange, Transform: funcapi.FieldTransformNumber, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummarySum, Chart: &funcapi.ChartOptions{Group: "Rows", Title: "Rows"}}, SelectExpr: "s.rows", sortOpt: true, sortLbl: "Top queries by Rows Returned"},
55 {ColumnMeta: funcapi.ColumnMeta{Name: "stddevTime", Tooltip: "Stddev Time", Type: funcapi.FieldTypeDuration, Units: "milliseconds", DecimalPoints: 2, Visible: false, Sortable: true, Filter: funcapi.FieldFilterRange, Transform: funcapi.FieldTransformDuration, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMax, Chart: &funcapi.ChartOptions{Group: "Time", Title: "Execution Time"}}, SelectExpr: "s.stddev_time"},
56 }
57
58 // funcapi.SortableColumn interface implementation for topQueriesColumn.
59 func (c topQueriesColumn) IsSortOption() bool { return c.sortOpt }
60 func (c topQueriesColumn) SortLabel() string { return c.sortLbl }
61 func (c topQueriesColumn) IsDefaultSort() bool { return c.defaultSort }
62 func (c topQueriesColumn) ColumnName() string { return c.Name }
63 func (c topQueriesColumn) SortColumn() string { return "" }
64
65 // funcTopQueries handles the top-queries function.
66 type funcTopQueries struct {
67 router *funcRouter
68 }
69
70 func newFuncTopQueries(r *funcRouter) *funcTopQueries {
71 return &funcTopQueries{router: r}
72 }
73
74 // Compile-time interface check.
75 var _ funcapi.MethodHandler = (*funcTopQueries)(nil)
76
77 func (f *funcTopQueries) MethodParams(ctx context.Context, method string) ([]funcapi.ParamConfig, error) {
78 if f.router.collector.Functions.TopQueries.Disabled {
79 return nil, fmt.Errorf("top-queries function disabled in configuration")
80 }
81 if err := f.router.ensureDB(ctx); err != nil {
82 return nil, nil // Use static RequiredParams
83 }
84 cols, err := f.availableColumns(ctx)
85 if err != nil {
86 return nil, nil // Use static RequiredParams
87 }
88 return []funcapi.ParamConfig{funcapi.BuildSortParam(cols)}, nil
89 }
90
91 func (f *funcTopQueries) Cleanup(ctx context.Context) {}
92
93 func (f *funcTopQueries) Handle(ctx context.Context, method string, params funcapi.ResolvedParams) *funcapi.FunctionResponse {
94 if f.router.collector.Functions.TopQueries.Disabled {
95 return funcapi.UnavailableResponse("top-queries function has been disabled in configuration")
96 }
97 if err := f.router.ensureDB(ctx); err != nil {
98 status := 503
99 if errors.Is(err, errSQLDSNNotSet) {
100 status = 400
101 }
102 return funcapi.ErrorResponse(status, "%s", err)
103 }
104
105 ok, err := f.pgStatStatementsEnabled(ctx)
106 if err != nil {
107 return funcapi.InternalErrorResponse("failed to check pg_stat_statements: %v", err)
108 }
109 if !ok {
110 return funcapi.UnavailableResponse(
111 "pg_stat_statements extension is not installed in this database. " +
112 "Run 'CREATE EXTENSION pg_stat_statements;' in the database the collector connects to.",
113 )
114 }
115
116 cols, err := f.availableColumns(ctx)
117 if err != nil {
118 return funcapi.InternalErrorResponse("%s", err)
119 }
120
121 sortColumn := f.resolveSortColumn(cols, params.Column("__sort"))
122 limit := f.router.topQueriesLimit()
123
124 query := f.buildSQL(cols, sortColumn)
125 queryCtx, cancel := context.WithTimeout(ctx, f.router.collector.topQueriesTimeout())
126 defer cancel()
127
128 rows, err := f.router.db.QueryContext(queryCtx, query, limit)
129 if err != nil {
130 if errors.Is(queryCtx.Err(), context.DeadlineExceeded) {
131 return funcapi.ErrorResponse(504, "query timed out")
132 }
133 return funcapi.InternalErrorResponse("query failed: %v", err)
134 }
135 defer rows.Close()
136
137 data, err := f.scanRows(rows, cols)
138 if err != nil {
139 return funcapi.InternalErrorResponse("%s", err)
140 }
141
142 cs := f.columnSet(cols)
143 return &funcapi.FunctionResponse{
144 Status: 200,
145 Help: "Top SQL queries from pg_stat_statements. WARNING: Query text may contain unmasked literals (potential PII).",
146 Columns: cs.BuildColumns(),
147 Data: data,
148 DefaultSortColumn: sortColumn,
149 RequiredParams: []funcapi.ParamConfig{funcapi.BuildSortParam(cols)},
150 ChartingConfig: cs.BuildCharting(),
151 }
152 }
153
154 func (f *funcTopQueries) columnSet(cols []topQueriesColumn) funcapi.ColumnSet[topQueriesColumn] {
155 return funcapi.Columns(cols, func(c topQueriesColumn) funcapi.ColumnMeta { return c.ColumnMeta })
156 }
157
158 func (f *funcTopQueries) availableColumns(ctx context.Context) ([]topQueriesColumn, error) {
159 available, err := f.detectPgStatStatementsColumns(ctx)
160 if err != nil {
161 return nil, fmt.Errorf("failed to detect available columns: %v", err)
162 }
163 cols := f.buildAvailableColumns(available)
164 if len(cols) == 0 {
165 return nil, fmt.Errorf("no queryable columns found in pg_stat_statements")
166 }
167 return cols, nil
168 }
169
170 func (f *funcTopQueries) pgStatStatementsEnabled(ctx context.Context) (bool, error) {
171 query := `SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'`
172 queryCtx, cancel := context.WithTimeout(ctx, f.router.collector.topQueriesTimeout())
173 defer cancel()
174
175 var exists int
176 if err := f.router.db.QueryRowContext(queryCtx, query).Scan(&exists); err != nil {
177 if errors.Is(queryCtx.Err(), context.DeadlineExceeded) {
178 return false, queryCtx.Err()
179 }
180 if errors.Is(err, sql.ErrNoRows) {
181 return false, nil
182 }
183 return false, err
184 }
185 return true, nil
186 }
187
188 func (f *funcTopQueries) detectPgStatStatementsColumns(ctx context.Context) (map[string]bool, error) {
189 f.router.pgStatStatementsColumnsMu.RLock()
190 if f.router.pgStatStatementsColumns != nil {
191 cols := f.router.pgStatStatementsColumns
192 f.router.pgStatStatementsColumnsMu.RUnlock()
193 return cols, nil
194 }
195 f.router.pgStatStatementsColumnsMu.RUnlock()
196
197 f.router.pgStatStatementsColumnsMu.Lock()
198 defer f.router.pgStatStatementsColumnsMu.Unlock()
199
200 if f.router.pgStatStatementsColumns != nil {
201 return f.router.pgStatStatementsColumns, nil
202 }
203
204 queryCtx, cancel := context.WithTimeout(ctx, f.router.collector.topQueriesTimeout())
205 defer cancel()
206
207 cols, err := sqlquery.FetchTableColumns(
208 queryCtx,
209 f.router.db,
210 "public",
211 "pg_stat_statements",
212 sqlquery.PlaceholderDollar,
213 nil,
214 )
215 if err != nil {
216 return nil, fmt.Errorf("failed to query columns: %v", err)
217 }
218
219 f.router.pgStatStatementsColumns = cols
220 return cols, nil
221 }
222
223 func (f *funcTopQueries) buildAvailableColumns(availableCols map[string]bool) []topQueriesColumn {
224 result := make([]topQueriesColumn, 0, len(topQueriesColumns))
225 for _, col := range topQueriesColumns {
226 if col.IsJoinColumn {
227 result = append(result, col)
228 continue
229 }
230 actual, ok := f.resolveColumnExpr(col.SelectExpr, availableCols)
231 if !ok {
232 continue
233 }
234 colCopy := col
235 colCopy.SelectExpr = actual
236 result = append(result, colCopy)
237 }
238 return result
239 }
240
241 func (f *funcTopQueries) resolveColumnExpr(expr string, availableCols map[string]bool) (string, bool) {
242 colName := expr
243 if idx := strings.LastIndex(colName, "."); idx != -1 {
244 colName = colName[idx+1:]
245 }
246
247 castSuffix := ""
248 if idx := strings.Index(colName, "::"); idx != -1 {
249 castSuffix = colName[idx:]
250 colName = colName[:idx]
251 }
252
253 actual := colName
254 switch colName {
255 case "total_time":
256 if availableCols["total_exec_time"] {
257 actual = "total_exec_time"
258 }
259 case "mean_time":
260 if availableCols["mean_exec_time"] {
261 actual = "mean_exec_time"
262 }
263 case "min_time":
264 if availableCols["min_exec_time"] {
265 actual = "min_exec_time"
266 }
267 case "max_time":
268 if availableCols["max_exec_time"] {
269 actual = "max_exec_time"
270 }
271 case "stddev_time":
272 if availableCols["stddev_exec_time"] {
273 actual = "stddev_exec_time"
274 }
275 }
276
277 if !availableCols[actual] {
278 return "", false
279 }
280
281 if actual == colName {
282 return expr, true
283 }
284 return strings.Replace(expr, colName+castSuffix, actual+castSuffix, 1), true
285 }
286
287 func (f *funcTopQueries) resolveSortColumn(cols []topQueriesColumn, requested string) string {
288 if requested != "" {
289 for _, col := range cols {
290 if col.Name == requested && col.IsSortOption() {
291 return col.Name
292 }
293 }
294 }
295 for _, col := range cols {
296 if col.IsDefaultSort() && col.IsSortOption() {
297 return col.Name
298 }
299 }
300 for _, col := range cols {
301 if col.IsSortOption() {
302 return col.Name
303 }
304 }
305 if len(cols) > 0 {
306 return cols[0].Name
307 }
308 return ""
309 }
310
311 func (f *funcTopQueries) buildSQL(cols []topQueriesColumn, sortColumn string) string {
312 selectCols := make([]string, 0, len(cols))
313 for _, col := range cols {
314 selectCols = append(selectCols, fmt.Sprintf("%s AS %s", col.SelectExpr, col.Name))
315 }
316
317 return fmt.Sprintf(`
318 SELECT %s
319 FROM pg_stat_statements s
320 JOIN pg_database d ON d.oid = s.dbid
321 JOIN pg_user u ON u.usesysid = s.userid
322 ORDER BY %s DESC NULLS LAST
323 LIMIT $1`, strings.Join(selectCols, ", "), sortColumn)
324 }
325
326 func (f *funcTopQueries) scanRows(rows *sql.Rows, cols []topQueriesColumn) ([][]any, error) {
327 specs := make([]sqlquery.ScanColumnSpec, len(cols))
328 for i, col := range cols {
329 specs[i] = yugabyteTopQueriesScanSpec(col)
330 }
331
332 data, err := sqlquery.ScanTypedRows(rows, specs)
333 if err != nil {
334 return nil, fmt.Errorf("row scan failed: %w", err)
335 }
336 if err := rows.Err(); err != nil {
337 return nil, fmt.Errorf("rows iteration error: %w", err)
338 }
339 return data, nil
340 }
341
342 func yugabyteTopQueriesScanSpec(col topQueriesColumn) sqlquery.ScanColumnSpec {
343 spec := sqlquery.ScanColumnSpec{}
344 switch col.Type {
345 case funcapi.FieldTypeString:
346 spec.Type = sqlquery.ScanValueString
347 if col.Name == "query" {
348 spec.Transform = func(v any) any {
349 s, _ := v.(string)
350 return strmutil.TruncateText(s, topQueriesMaxTextLength)
351 }
352 }
353 case funcapi.FieldTypeInteger:
354 spec.Type = sqlquery.ScanValueInteger
355 case funcapi.FieldTypeFloat, funcapi.FieldTypeDuration:
356 spec.Type = sqlquery.ScanValueFloat
357 default:
358 spec.Type = sqlquery.ScanValueDiscard
359 }
360 return spec
361 }