| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package cockroachdb |
| 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/strmutil" |
| 14 | ) |
| 15 | |
| 16 | const ( |
| 17 | topQueriesMethodID = "top-queries" |
| 18 | topQueriesMaxTextLength = 4096 |
| 19 | ) |
| 20 | |
| 21 | func topQueriesMethodConfig() funcapi.MethodConfig { |
| 22 | return funcapi.MethodConfig{ |
| 23 | ID: topQueriesMethodID, |
| 24 | Name: "Top Queries", |
| 25 | UpdateEvery: 10, |
| 26 | Help: "Top SQL statements from crdb_internal.cluster_statement_statistics. WARNING: Query text may contain unmasked literals (potential PII).", |
| 27 | RequireCloud: true, |
| 28 | RequiredParams: []funcapi.ParamConfig{funcapi.BuildSortParam(topQueriesColumns)}, |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | // topQueriesColumn embeds funcapi.ColumnMeta and adds CockroachDB-specific fields. |
| 33 | type topQueriesColumn struct { |
| 34 | funcapi.ColumnMeta |
| 35 | SelectExpr string // SQL expression for SELECT clause |
| 36 | sortOpt bool // whether this column appears as a sort option |
| 37 | sortLbl string // label for sort option dropdown |
| 38 | defaultSort bool // default sort column |
| 39 | } |
| 40 | |
| 41 | // funcapi.SortableColumn interface implementation for topQueriesColumn. |
| 42 | func (c topQueriesColumn) IsSortOption() bool { return c.sortOpt } |
| 43 | func (c topQueriesColumn) SortLabel() string { return c.sortLbl } |
| 44 | func (c topQueriesColumn) IsDefaultSort() bool { return c.defaultSort } |
| 45 | func (c topQueriesColumn) ColumnName() string { return c.Name } |
| 46 | func (c topQueriesColumn) SortColumn() string { return "" } |
| 47 | |
| 48 | var topQueriesColumns = []topQueriesColumn{ |
| 49 | {ColumnMeta: funcapi.ColumnMeta{Name: "fingerprintId", Tooltip: "Fingerprint ID", Type: funcapi.FieldTypeString, Visible: false, Sortable: true, Filter: funcapi.FieldFilterMultiselect, Transform: funcapi.FieldTransformText, UniqueKey: true, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount}, SelectExpr: "s.fingerprint_id::STRING"}, |
| 50 | {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.metadata->>'query'"}, |
| 51 | {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: "s.metadata->>'db'"}, |
| 52 | {ColumnMeta: funcapi.ColumnMeta{Name: "application", Tooltip: "Application", Type: funcapi.FieldTypeString, Visible: true, Sortable: true, Filter: funcapi.FieldFilterMultiselect, Transform: funcapi.FieldTransformText, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, GroupBy: &funcapi.GroupByOptions{}}, SelectExpr: "s.app_name"}, |
| 53 | {ColumnMeta: funcapi.ColumnMeta{Name: "statementType", Tooltip: "Statement Type", Type: funcapi.FieldTypeString, Visible: false, Sortable: true, Filter: funcapi.FieldFilterMultiselect, Transform: funcapi.FieldTransformText, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, GroupBy: &funcapi.GroupByOptions{}}, SelectExpr: "s.metadata->>'stmtTyp'"}, |
| 54 | {ColumnMeta: funcapi.ColumnMeta{Name: "distributed", Tooltip: "Distributed", Type: funcapi.FieldTypeString, Visible: false, Sortable: true, Filter: funcapi.FieldFilterMultiselect, Transform: funcapi.FieldTransformText, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount}, SelectExpr: "s.metadata->>'distsql'"}, |
| 55 | {ColumnMeta: funcapi.ColumnMeta{Name: "fullScan", Tooltip: "Full Scan", Type: funcapi.FieldTypeString, Visible: false, Sortable: true, Filter: funcapi.FieldFilterMultiselect, Transform: funcapi.FieldTransformText, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount}, SelectExpr: "s.metadata->>'fullScan'"}, |
| 56 | {ColumnMeta: funcapi.ColumnMeta{Name: "implicitTxn", Tooltip: "Implicit Txn", Type: funcapi.FieldTypeString, Visible: false, Sortable: true, Filter: funcapi.FieldFilterMultiselect, Transform: funcapi.FieldTransformText, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount}, SelectExpr: "s.metadata->>'implicitTxn'"}, |
| 57 | {ColumnMeta: funcapi.ColumnMeta{Name: "vectorized", Tooltip: "Vectorized", Type: funcapi.FieldTypeString, Visible: false, Sortable: true, Filter: funcapi.FieldFilterMultiselect, Transform: funcapi.FieldTransformText, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount}, SelectExpr: "s.metadata->>'vec'"}, |
| 58 | |
| 59 | {ColumnMeta: funcapi.ColumnMeta{Name: "executions", Tooltip: "Executions", 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: "Executions", IsDefault: true}}, SelectExpr: "COALESCE((s.statistics->'statistics'->>'cnt')::INT8, 0)", sortOpt: true, sortLbl: "Top queries by Executions"}, |
| 60 | {ColumnMeta: funcapi.ColumnMeta{Name: "totalTime", Tooltip: "Total Time", Type: funcapi.FieldTypeDuration, Visible: true, Sortable: true, Filter: funcapi.FieldFilterRange, Visualization: funcapi.FieldVisualBar, Transform: funcapi.FieldTransformDuration, Units: "milliseconds", DecimalPoints: 2, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummarySum, Chart: &funcapi.ChartOptions{Group: "Time", Title: "Execution Time", IsDefault: true}}, SelectExpr: "COALESCE((s.statistics->'statistics'->'svcLat'->>'mean')::FLOAT8, 0) * COALESCE((s.statistics->'statistics'->>'cnt')::FLOAT8, 0) * 1000", sortOpt: true, defaultSort: true, sortLbl: "Top queries by Total Time"}, |
| 61 | {ColumnMeta: funcapi.ColumnMeta{Name: "meanTime", Tooltip: "Mean Time", Type: funcapi.FieldTypeDuration, Visible: true, Sortable: true, Filter: funcapi.FieldFilterRange, Visualization: funcapi.FieldVisualBar, Transform: funcapi.FieldTransformDuration, Units: "milliseconds", DecimalPoints: 2, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMean, Chart: &funcapi.ChartOptions{Group: "Time", Title: "Execution Time"}}, SelectExpr: "COALESCE((s.statistics->'statistics'->'svcLat'->>'mean')::FLOAT8, 0) * 1000", sortOpt: true, sortLbl: "Top queries by Mean Time"}, |
| 62 | {ColumnMeta: funcapi.ColumnMeta{Name: "runTime", Tooltip: "Run Time", Type: funcapi.FieldTypeDuration, Visible: false, Sortable: true, Filter: funcapi.FieldFilterRange, Visualization: funcapi.FieldVisualBar, Transform: funcapi.FieldTransformDuration, Units: "milliseconds", DecimalPoints: 2, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMean, Chart: &funcapi.ChartOptions{Group: "Time", Title: "Execution Time"}}, SelectExpr: "COALESCE((s.statistics->'statistics'->'runLat'->>'mean')::FLOAT8, 0) * 1000"}, |
| 63 | {ColumnMeta: funcapi.ColumnMeta{Name: "planTime", Tooltip: "Plan Time", Type: funcapi.FieldTypeDuration, Visible: false, Sortable: true, Filter: funcapi.FieldFilterRange, Visualization: funcapi.FieldVisualBar, Transform: funcapi.FieldTransformDuration, Units: "milliseconds", DecimalPoints: 2, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMean, Chart: &funcapi.ChartOptions{Group: "Time", Title: "Execution Time"}}, SelectExpr: "COALESCE((s.statistics->'statistics'->'planLat'->>'mean')::FLOAT8, 0) * 1000"}, |
| 64 | {ColumnMeta: funcapi.ColumnMeta{Name: "parseTime", Tooltip: "Parse Time", Type: funcapi.FieldTypeDuration, Visible: false, Sortable: true, Filter: funcapi.FieldFilterRange, Visualization: funcapi.FieldVisualBar, Transform: funcapi.FieldTransformDuration, Units: "milliseconds", DecimalPoints: 2, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMean, Chart: &funcapi.ChartOptions{Group: "Time", Title: "Execution Time"}}, SelectExpr: "COALESCE((s.statistics->'statistics'->'parseLat'->>'mean')::FLOAT8, 0) * 1000"}, |
| 65 | |
| 66 | {ColumnMeta: funcapi.ColumnMeta{Name: "rowsRead", Tooltip: "Rows Read", 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: "CAST(ROUND(COALESCE((s.statistics->'statistics'->'rowsRead'->>'mean')::FLOAT8, 0) * COALESCE((s.statistics->'statistics'->>'cnt')::FLOAT8, 0)) AS INT8)", sortOpt: true, sortLbl: "Top queries by Rows Read"}, |
| 67 | {ColumnMeta: funcapi.ColumnMeta{Name: "rowsWritten", Tooltip: "Rows Written", 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: "CAST(ROUND(COALESCE((s.statistics->'statistics'->'rowsWritten'->>'mean')::FLOAT8, 0) * COALESCE((s.statistics->'statistics'->>'cnt')::FLOAT8, 0)) AS INT8)", sortOpt: true, sortLbl: "Top queries by Rows Written"}, |
| 68 | {ColumnMeta: funcapi.ColumnMeta{Name: "rowsReturned", Tooltip: "Rows Returned", 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: "CAST(ROUND(COALESCE((s.statistics->'statistics'->'numRows'->>'mean')::FLOAT8, 0) * COALESCE((s.statistics->'statistics'->>'cnt')::FLOAT8, 0)) AS INT8)", sortOpt: true, sortLbl: "Top queries by Rows Returned"}, |
| 69 | {ColumnMeta: funcapi.ColumnMeta{Name: "bytesRead", Tooltip: "Bytes Read", Type: funcapi.FieldTypeInteger, Visible: false, Sortable: true, Filter: funcapi.FieldFilterRange, Transform: funcapi.FieldTransformNumber, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummarySum, Chart: &funcapi.ChartOptions{Group: "Bytes", Title: "Bytes"}}, SelectExpr: "CAST(ROUND(COALESCE((s.statistics->'statistics'->'bytesRead'->>'mean')::FLOAT8, 0) * COALESCE((s.statistics->'statistics'->>'cnt')::FLOAT8, 0)) AS INT8)", sortOpt: true, sortLbl: "Top queries by Bytes Read"}, |
| 70 | {ColumnMeta: funcapi.ColumnMeta{Name: "maxRetries", Tooltip: "Max Retries", Type: funcapi.FieldTypeInteger, Visible: false, Sortable: true, Filter: funcapi.FieldFilterRange, Transform: funcapi.FieldTransformNumber, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMax, Chart: &funcapi.ChartOptions{Group: "Retries", Title: "Retries"}}, SelectExpr: "COALESCE((s.statistics->'statistics'->>'maxRetries')::INT8, 0)"}, |
| 71 | } |
| 72 | |
| 73 | // funcTopQueries handles the top-queries function. |
| 74 | type funcTopQueries struct { |
| 75 | router *funcRouter |
| 76 | } |
| 77 | |
| 78 | func newFuncTopQueries(r *funcRouter) *funcTopQueries { |
| 79 | return &funcTopQueries{router: r} |
| 80 | } |
| 81 | |
| 82 | // Compile-time interface check. |
| 83 | var _ funcapi.MethodHandler = (*funcTopQueries)(nil) |
| 84 | |
| 85 | func (f *funcTopQueries) MethodParams(ctx context.Context, method string) ([]funcapi.ParamConfig, error) { |
| 86 | if f.router.collector.Functions.TopQueries.Disabled { |
| 87 | return nil, fmt.Errorf("top-queries function disabled in configuration") |
| 88 | } |
| 89 | return []funcapi.ParamConfig{funcapi.BuildSortParam(topQueriesColumns)}, nil |
| 90 | } |
| 91 | |
| 92 | func (f *funcTopQueries) Cleanup(ctx context.Context) {} |
| 93 | |
| 94 | func (f *funcTopQueries) Handle(ctx context.Context, method string, params funcapi.ResolvedParams) *funcapi.FunctionResponse { |
| 95 | if f.router.collector.Functions.TopQueries.Disabled { |
| 96 | return funcapi.UnavailableResponse("top-queries function has been disabled in configuration") |
| 97 | } |
| 98 | if err := f.router.ensureDB(ctx); err != nil { |
| 99 | status := 503 |
| 100 | if errors.Is(err, errSQLDSNNotSet) { |
| 101 | status = 400 |
| 102 | } |
| 103 | return funcapi.ErrorResponse(status, "%s", err) |
| 104 | } |
| 105 | |
| 106 | sortColumn := f.resolveSortColumn(params.Column("__sort")) |
| 107 | limit := f.router.topQueriesLimit() |
| 108 | |
| 109 | query := f.buildSQL(sortColumn) |
| 110 | queryCtx, cancel := context.WithTimeout(ctx, f.router.collector.topQueriesTimeout()) |
| 111 | defer cancel() |
| 112 | |
| 113 | rows, err := f.router.db.QueryContext(queryCtx, query, limit) |
| 114 | if err != nil { |
| 115 | if errors.Is(queryCtx.Err(), context.DeadlineExceeded) { |
| 116 | return funcapi.ErrorResponse(504, "query timed out") |
| 117 | } |
| 118 | return funcapi.InternalErrorResponse("query failed: %v", err) |
| 119 | } |
| 120 | defer rows.Close() |
| 121 | |
| 122 | data, err := f.scanRows(rows) |
| 123 | if err != nil { |
| 124 | return funcapi.InternalErrorResponse("%s", err) |
| 125 | } |
| 126 | |
| 127 | cs := f.columnSet() |
| 128 | return &funcapi.FunctionResponse{ |
| 129 | Status: 200, |
| 130 | Help: "Top SQL statements from crdb_internal.cluster_statement_statistics. WARNING: Query text may contain unmasked literals (potential PII).", |
| 131 | Columns: cs.BuildColumns(), |
| 132 | Data: data, |
| 133 | DefaultSortColumn: sortColumn, |
| 134 | RequiredParams: []funcapi.ParamConfig{funcapi.BuildSortParam(topQueriesColumns)}, |
| 135 | ChartingConfig: cs.BuildCharting(), |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | func (f *funcTopQueries) columnSet() funcapi.ColumnSet[topQueriesColumn] { |
| 140 | return funcapi.Columns(topQueriesColumns, func(c topQueriesColumn) funcapi.ColumnMeta { return c.ColumnMeta }) |
| 141 | } |
| 142 | |
| 143 | func (f *funcTopQueries) resolveSortColumn(requested string) string { |
| 144 | if requested != "" { |
| 145 | for _, col := range topQueriesColumns { |
| 146 | if col.Name == requested && col.IsSortOption() { |
| 147 | return col.Name |
| 148 | } |
| 149 | } |
| 150 | } |
| 151 | for _, col := range topQueriesColumns { |
| 152 | if col.IsDefaultSort() && col.IsSortOption() { |
| 153 | return col.Name |
| 154 | } |
| 155 | } |
| 156 | for _, col := range topQueriesColumns { |
| 157 | if col.IsSortOption() { |
| 158 | return col.Name |
| 159 | } |
| 160 | } |
| 161 | if len(topQueriesColumns) > 0 { |
| 162 | return topQueriesColumns[0].Name |
| 163 | } |
| 164 | return "" |
| 165 | } |
| 166 | |
| 167 | func (f *funcTopQueries) buildSQL(sortColumn string) string { |
| 168 | selectCols := make([]string, 0, len(topQueriesColumns)) |
| 169 | for _, col := range topQueriesColumns { |
| 170 | selectCols = append(selectCols, fmt.Sprintf("%s AS %s", col.SelectExpr, col.Name)) |
| 171 | } |
| 172 | return fmt.Sprintf(` |
| 173 | SELECT %s |
| 174 | FROM crdb_internal.cluster_statement_statistics AS s |
| 175 | WHERE s.metadata->>'query' IS NOT NULL |
| 176 | ORDER BY %s DESC NULLS LAST |
| 177 | LIMIT $1`, strings.Join(selectCols, ", "), sortColumn) |
| 178 | } |
| 179 | |
| 180 | func (f *funcTopQueries) scanRows(rows *sql.Rows) ([][]any, error) { |
| 181 | cols := topQueriesColumns |
| 182 | data := make([][]any, 0, 500) |
| 183 | |
| 184 | for rows.Next() { |
| 185 | values := make([]any, len(cols)) |
| 186 | valuePtrs := make([]any, len(cols)) |
| 187 | |
| 188 | for i, col := range cols { |
| 189 | switch col.Type { |
| 190 | case funcapi.FieldTypeString: |
| 191 | var v sql.NullString |
| 192 | values[i] = &v |
| 193 | case funcapi.FieldTypeInteger: |
| 194 | var v sql.NullInt64 |
| 195 | values[i] = &v |
| 196 | case funcapi.FieldTypeFloat, funcapi.FieldTypeDuration: |
| 197 | var v sql.NullFloat64 |
| 198 | values[i] = &v |
| 199 | default: |
| 200 | var v any |
| 201 | values[i] = &v |
| 202 | } |
| 203 | valuePtrs[i] = values[i] |
| 204 | } |
| 205 | |
| 206 | if err := rows.Scan(valuePtrs...); err != nil { |
| 207 | return nil, fmt.Errorf("row scan failed: %w", err) |
| 208 | } |
| 209 | |
| 210 | row := make([]any, len(cols)) |
| 211 | for i, col := range cols { |
| 212 | switch v := values[i].(type) { |
| 213 | case *sql.NullString: |
| 214 | if v.Valid { |
| 215 | s := v.String |
| 216 | if col.Name == "query" { |
| 217 | s = strmutil.TruncateText(s, topQueriesMaxTextLength) |
| 218 | } |
| 219 | row[i] = s |
| 220 | } else { |
| 221 | row[i] = "" |
| 222 | } |
| 223 | case *sql.NullInt64: |
| 224 | if v.Valid { |
| 225 | row[i] = v.Int64 |
| 226 | } else { |
| 227 | row[i] = int64(0) |
| 228 | } |
| 229 | case *sql.NullFloat64: |
| 230 | if v.Valid { |
| 231 | row[i] = v.Float64 |
| 232 | } else { |
| 233 | row[i] = float64(0) |
| 234 | } |
| 235 | default: |
| 236 | row[i] = nil |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | data = append(data, row) |
| 241 | } |
| 242 | |
| 243 | if err := rows.Err(); err != nil { |
| 244 | return nil, fmt.Errorf("rows iteration error: %w", err) |
| 245 | } |
| 246 | |
| 247 | return data, nil |
| 248 | } |