master
go 321 lines 15.1 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package clickhouse
4
5 import (
6 "context"
7 "encoding/json"
8 "fmt"
9 "strconv"
10 "strings"
11
12 "github.com/netdata/netdata/go/plugins/pkg/funcapi"
13 "github.com/netdata/netdata/go/plugins/pkg/web"
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 ClickHouse system.query_log",
28 RequireCloud: true,
29 RequiredParams: []funcapi.ParamConfig{funcapi.BuildSortParam(topQueriesColumns)},
30 }
31 }
32
33 // topQueriesColumn defines a column for ClickHouse top-queries function.
34 // Embeds funcapi.ColumnMeta for UI display and adds collector-specific fields.
35 type topQueriesColumn struct {
36 funcapi.ColumnMeta
37
38 // Data access
39 DBColumn string // Column name in system.query_log (empty = computed)
40 SelectExpr string // SQL expression for SELECT
41
42 // Sort parameter metadata
43 sortOpt bool // Include in __sort parameter options
44 sortLbl string // Display label for sort option
45 defaultSort bool // Default sort option
46 }
47
48 // funcapi.SortableColumn interface implementation for topQueriesColumn.
49 func (c topQueriesColumn) IsSortOption() bool { return c.sortOpt }
50 func (c topQueriesColumn) SortLabel() string { return c.sortLbl }
51 func (c topQueriesColumn) IsDefaultSort() bool { return c.defaultSort }
52 func (c topQueriesColumn) ColumnName() string { return c.Name }
53 func (c topQueriesColumn) SortColumn() string { return "" }
54
55 var topQueriesColumns = []topQueriesColumn{
56 {ColumnMeta: funcapi.ColumnMeta{Name: "queryId", Tooltip: "Query ID", Type: funcapi.FieldTypeString, Visible: false, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, Filter: funcapi.FieldFilterMultiselect, UniqueKey: true, Sortable: true}, DBColumn: "normalized_query_hash", SelectExpr: "toString(normalized_query_hash)"},
57 {ColumnMeta: funcapi.ColumnMeta{Name: "query", Tooltip: "Query", Type: funcapi.FieldTypeString, Visible: true, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, Filter: funcapi.FieldFilterMultiselect, Sticky: true, FullWidth: true, Sortable: true}, DBColumn: "query", SelectExpr: "any(query)"},
58 {ColumnMeta: funcapi.ColumnMeta{Name: "database", Tooltip: "Database", Type: funcapi.FieldTypeString, Visible: true, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, Filter: funcapi.FieldFilterMultiselect, GroupBy: &funcapi.GroupByOptions{IsDefault: true}, Sortable: true}, DBColumn: "current_database", SelectExpr: "any(current_database)"},
59 {ColumnMeta: funcapi.ColumnMeta{Name: "user", Tooltip: "User", Type: funcapi.FieldTypeString, Visible: true, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, Filter: funcapi.FieldFilterMultiselect, GroupBy: &funcapi.GroupByOptions{}, Sortable: true}, DBColumn: "user", SelectExpr: "any(user)"},
60
61 {ColumnMeta: funcapi.ColumnMeta{Name: "calls", Tooltip: "Calls", Type: funcapi.FieldTypeInteger, Visible: true, Transform: funcapi.FieldTransformNumber, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummarySum, Filter: funcapi.FieldFilterRange, Chart: &funcapi.ChartOptions{Group: "Calls", Title: "Number of Calls", IsDefault: true}, Sortable: true}, SelectExpr: "count()", sortOpt: true, sortLbl: "Top queries by Number of Calls"},
62
63 {ColumnMeta: funcapi.ColumnMeta{Name: "totalTime", Tooltip: "Total Time", Type: funcapi.FieldTypeDuration, Units: "milliseconds", Visible: true, Transform: funcapi.FieldTransformDuration, DecimalPoints: 2, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummarySum, Filter: funcapi.FieldFilterRange, Chart: &funcapi.ChartOptions{Group: "Time", Title: "Execution Time", IsDefault: true}, Sortable: true}, DBColumn: "query_duration_ms", SelectExpr: "sum(query_duration_ms)", sortOpt: true, defaultSort: true, sortLbl: "Top queries by Total Execution Time"},
64 {ColumnMeta: funcapi.ColumnMeta{Name: "avgTime", Tooltip: "Avg Time", Type: funcapi.FieldTypeDuration, Units: "milliseconds", Visible: true, Transform: funcapi.FieldTransformDuration, DecimalPoints: 2, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMean, Filter: funcapi.FieldFilterRange, Chart: &funcapi.ChartOptions{Group: "Time", Title: "Execution Time"}, Sortable: true}, DBColumn: "query_duration_ms", SelectExpr: "avg(query_duration_ms)", sortOpt: true, sortLbl: "Top queries by Average Execution Time"},
65 {ColumnMeta: funcapi.ColumnMeta{Name: "minTime", Tooltip: "Min Time", Type: funcapi.FieldTypeDuration, Units: "milliseconds", Visible: false, Transform: funcapi.FieldTransformDuration, DecimalPoints: 2, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMin, Filter: funcapi.FieldFilterRange, Chart: &funcapi.ChartOptions{Group: "Time", Title: "Execution Time"}, Sortable: true}, DBColumn: "query_duration_ms", SelectExpr: "min(query_duration_ms)"},
66 {ColumnMeta: funcapi.ColumnMeta{Name: "maxTime", Tooltip: "Max Time", Type: funcapi.FieldTypeDuration, Units: "milliseconds", Visible: false, Transform: funcapi.FieldTransformDuration, DecimalPoints: 2, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMax, Filter: funcapi.FieldFilterRange, Chart: &funcapi.ChartOptions{Group: "Time", Title: "Execution Time"}, Sortable: true}, DBColumn: "query_duration_ms", SelectExpr: "max(query_duration_ms)"},
67
68 {ColumnMeta: funcapi.ColumnMeta{Name: "readRows", Tooltip: "Read Rows", Type: funcapi.FieldTypeInteger, Visible: true, Transform: funcapi.FieldTransformNumber, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummarySum, Filter: funcapi.FieldFilterRange, Chart: &funcapi.ChartOptions{Group: "Rows", Title: "Rows"}, Sortable: true}, DBColumn: "read_rows", SelectExpr: "sum(read_rows)", sortOpt: true, sortLbl: "Top queries by Rows Read"},
69 {ColumnMeta: funcapi.ColumnMeta{Name: "readBytes", Tooltip: "Read Bytes", Type: funcapi.FieldTypeInteger, Visible: true, Transform: funcapi.FieldTransformNumber, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummarySum, Filter: funcapi.FieldFilterRange, Chart: &funcapi.ChartOptions{Group: "Bytes", Title: "Bytes"}, Sortable: true}, DBColumn: "read_bytes", SelectExpr: "sum(read_bytes)"},
70 {ColumnMeta: funcapi.ColumnMeta{Name: "writtenRows", Tooltip: "Written Rows", Type: funcapi.FieldTypeInteger, Visible: false, Transform: funcapi.FieldTransformNumber, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummarySum, Filter: funcapi.FieldFilterRange, Chart: &funcapi.ChartOptions{Group: "Rows", Title: "Rows"}, Sortable: true}, DBColumn: "written_rows", SelectExpr: "sum(written_rows)"},
71 {ColumnMeta: funcapi.ColumnMeta{Name: "writtenBytes", Tooltip: "Written Bytes", Type: funcapi.FieldTypeInteger, Visible: false, Transform: funcapi.FieldTransformNumber, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummarySum, Filter: funcapi.FieldFilterRange, Chart: &funcapi.ChartOptions{Group: "Bytes", Title: "Bytes"}, Sortable: true}, DBColumn: "written_bytes", SelectExpr: "sum(written_bytes)"},
72 {ColumnMeta: funcapi.ColumnMeta{Name: "resultRows", Tooltip: "Result Rows", Type: funcapi.FieldTypeInteger, Visible: true, Transform: funcapi.FieldTransformNumber, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummarySum, Filter: funcapi.FieldFilterRange, Chart: &funcapi.ChartOptions{Group: "Rows", Title: "Rows"}, Sortable: true}, DBColumn: "result_rows", SelectExpr: "sum(result_rows)"},
73 {ColumnMeta: funcapi.ColumnMeta{Name: "resultBytes", Tooltip: "Result Bytes", Type: funcapi.FieldTypeInteger, Visible: false, Transform: funcapi.FieldTransformNumber, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummarySum, Filter: funcapi.FieldFilterRange, Chart: &funcapi.ChartOptions{Group: "Bytes", Title: "Bytes"}, Sortable: true}, DBColumn: "result_bytes", SelectExpr: "sum(result_bytes)"},
74 {ColumnMeta: funcapi.ColumnMeta{Name: "memoryUsage", Tooltip: "Max Memory", Type: funcapi.FieldTypeFloat, Visible: false, Transform: funcapi.FieldTransformNumber, DecimalPoints: 0, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMax, Filter: funcapi.FieldFilterRange, Chart: &funcapi.ChartOptions{Group: "Memory", Title: "Memory"}, Sortable: true}, DBColumn: "memory_usage", SelectExpr: "max(memory_usage)"},
75 }
76
77 type topQueriesJSONResponse struct {
78 Data []map[string]any `json:"data"`
79 }
80
81 // funcTopQueries implements funcapi.MethodHandler for ClickHouse top-queries.
82 // All function-related logic is encapsulated here, keeping Collector focused on metrics collection.
83 type funcTopQueries struct {
84 router *funcRouter
85 }
86
87 func newFuncTopQueries(r *funcRouter) *funcTopQueries {
88 return &funcTopQueries{router: r}
89 }
90
91 // Compile-time interface check.
92 var _ funcapi.MethodHandler = (*funcTopQueries)(nil)
93
94 // MethodParams implements funcapi.MethodHandler.
95 func (f *funcTopQueries) MethodParams(ctx context.Context, method string) ([]funcapi.ParamConfig, error) {
96 if f.router.collector.httpClient == nil {
97 return nil, fmt.Errorf("collector is still initializing")
98 }
99 switch method {
100 case topQueriesMethodID:
101 if f.router.collector.Functions.TopQueries.Disabled {
102 return nil, fmt.Errorf("top-queries function disabled in configuration")
103 }
104 return f.methodParams(ctx)
105 default:
106 return nil, fmt.Errorf("unknown method: %s", method)
107 }
108 }
109
110 // Handle implements funcapi.MethodHandler.
111 func (f *funcTopQueries) Handle(ctx context.Context, method string, params funcapi.ResolvedParams) *funcapi.FunctionResponse {
112 if f.router.collector.httpClient == nil {
113 return funcapi.UnavailableResponse("collector is still initializing, please retry in a few seconds")
114 }
115 switch method {
116 case topQueriesMethodID:
117 if f.router.collector.Functions.TopQueries.Disabled {
118 return funcapi.UnavailableResponse("top-queries function has been disabled in configuration")
119 }
120 queryCtx, cancel := context.WithTimeout(ctx, f.router.collector.topQueriesTimeout())
121 defer cancel()
122 return f.collectData(queryCtx, params.Column("__sort"))
123 default:
124 return funcapi.NotFoundResponse(method)
125 }
126 }
127
128 // Cleanup implements funcapi.MethodHandler.
129 func (f *funcTopQueries) Cleanup(ctx context.Context) {}
130
131 func (f *funcTopQueries) methodParams(ctx context.Context) ([]funcapi.ParamConfig, error) {
132 available, err := f.detectQueryLogColumns(ctx)
133 if err != nil {
134 return nil, err
135 }
136 cols := f.buildAvailableColumns(available)
137 if len(cols) == 0 {
138 return nil, fmt.Errorf("no columns available in system.query_log")
139 }
140 return []funcapi.ParamConfig{funcapi.BuildSortParam(cols)}, nil
141 }
142
143 func (f *funcTopQueries) detectQueryLogColumns(ctx context.Context) (map[string]bool, error) {
144 query := `
145 SELECT name
146 FROM system.columns
147 WHERE database = 'system' AND table = 'query_log'
148 FORMAT JSON`
149
150 req, err := web.NewHTTPRequest(f.router.collector.RequestConfig)
151 if err != nil {
152 return nil, err
153 }
154 req = req.WithContext(ctx)
155 req.URL.RawQuery = makeURLQuery(query)
156
157 var resp topQueriesJSONResponse
158 if err := web.DoHTTP(f.router.collector.httpClient).RequestJSON(req, &resp); err != nil {
159 return nil, fmt.Errorf("failed to query system.columns: %w", err)
160 }
161
162 cols := make(map[string]bool, len(resp.Data))
163 for _, row := range resp.Data {
164 if name, ok := row["name"].(string); ok {
165 cols[name] = true
166 }
167 }
168 if len(cols) == 0 {
169 return nil, fmt.Errorf("system.query_log not available")
170 }
171 return cols, nil
172 }
173
174 func (f *funcTopQueries) collectData(ctx context.Context, sortColumn string) *funcapi.FunctionResponse {
175 availableCols, err := f.detectQueryLogColumns(ctx)
176 if err != nil {
177 return funcapi.ErrorResponse(503, "system.query_log not available: %v", err)
178 }
179
180 cols := f.buildAvailableColumns(availableCols)
181 if len(cols) == 0 {
182 return funcapi.ErrorResponse(500, "no columns available in system.query_log")
183 }
184
185 cs := f.columnSet(cols)
186 sortColumn = f.mapAndValidateSortColumn(sortColumn, cs)
187
188 limit := f.router.collector.topQueriesLimit()
189
190 groupKey := "normalized_query_hash"
191 if !availableCols[groupKey] {
192 groupKey = "query"
193 }
194
195 selectParts := make([]string, 0, len(cols))
196 for _, col := range cols {
197 selectParts = append(selectParts, fmt.Sprintf("%s AS `%s`", col.SelectExpr, col.Name))
198 }
199
200 query := fmt.Sprintf(`
201 SELECT %s
202 FROM system.query_log
203 WHERE type = 'QueryFinish'
204 GROUP BY %s
205 ORDER BY `+"`%s`"+` DESC
206 LIMIT %d
207 FORMAT JSON
208 `, strings.Join(selectParts, ", "), groupKey, sortColumn, limit)
209
210 req, err := web.NewHTTPRequest(f.router.collector.RequestConfig)
211 if err != nil {
212 return funcapi.ErrorResponse(500, "%v", err)
213 }
214 req = req.WithContext(ctx)
215 req.URL.RawQuery = makeURLQuery(query)
216
217 var resp topQueriesJSONResponse
218 if err := web.DoHTTP(f.router.collector.httpClient).RequestJSON(req, &resp); err != nil {
219 if ctx.Err() == context.DeadlineExceeded {
220 return funcapi.ErrorResponse(504, "query timed out")
221 }
222 return funcapi.ErrorResponse(500, "query failed: %v", err)
223 }
224
225 data := make([][]any, 0, len(resp.Data))
226 for _, rowMap := range resp.Data {
227 row := make([]any, len(cols))
228 for i, col := range cols {
229 row[i] = f.normalizeValue(col, rowMap[col.Name])
230 }
231 data = append(data, row)
232 }
233
234 return &funcapi.FunctionResponse{
235 Status: 200,
236 Help: "Top SQL queries from ClickHouse system.query_log",
237 Columns: cs.BuildColumns(),
238 Data: data,
239 DefaultSortColumn: sortColumn,
240 RequiredParams: []funcapi.ParamConfig{funcapi.BuildSortParam(cols)},
241 ChartingConfig: cs.BuildCharting(),
242 }
243 }
244
245 func (f *funcTopQueries) columnSet(cols []topQueriesColumn) funcapi.ColumnSet[topQueriesColumn] {
246 return funcapi.Columns(cols, func(c topQueriesColumn) funcapi.ColumnMeta { return c.ColumnMeta })
247 }
248
249 func (f *funcTopQueries) buildAvailableColumns(available map[string]bool) []topQueriesColumn {
250 var cols []topQueriesColumn
251 for _, col := range topQueriesColumns {
252 if col.DBColumn == "" || available[col.DBColumn] {
253 cols = append(cols, col)
254 }
255 }
256 return cols
257 }
258
259 func (f *funcTopQueries) mapAndValidateSortColumn(input string, cs funcapi.ColumnSet[topQueriesColumn]) string {
260 if cs.ContainsColumn(input) {
261 return input
262 }
263 if cs.ContainsColumn("totalTime") {
264 return "totalTime"
265 }
266 if cs.ContainsColumn("calls") {
267 return "calls"
268 }
269 names := cs.Names()
270 if len(names) > 0 {
271 return names[0]
272 }
273 return ""
274 }
275
276 func (f *funcTopQueries) normalizeValue(col topQueriesColumn, v any) any {
277 switch col.Type {
278 case funcapi.FieldTypeInteger:
279 switch val := v.(type) {
280 case float64:
281 return int64(val)
282 case json.Number:
283 if i, err := val.Int64(); err == nil {
284 return i
285 }
286 case string:
287 if i, err := strconv.ParseInt(val, 10, 64); err == nil {
288 return i
289 }
290 }
291 return int64(0)
292 case funcapi.FieldTypeFloat, funcapi.FieldTypeDuration:
293 switch val := v.(type) {
294 case float64:
295 return val
296 case json.Number:
297 if f, err := val.Float64(); err == nil {
298 return f
299 }
300 case string:
301 if f, err := strconv.ParseFloat(val, 64); err == nil {
302 return f
303 }
304 }
305 return float64(0)
306 default:
307 if s, ok := v.(string); ok {
308 if col.Name == "query" {
309 return strmutil.TruncateText(s, topQueriesMaxTextLength)
310 }
311 return s
312 }
313 if v == nil {
314 return ""
315 }
316 if col.Name == "query" {
317 return strmutil.TruncateText(fmt.Sprint(v), topQueriesMaxTextLength)
318 }
319 return fmt.Sprint(v)
320 }
321 }