From d095645d613f586818a6f467f2a309eda693976c Mon Sep 17 00:00:00 2001 From: Pandey Date: Mon, 20 Jul 2026 22:01:47 +0530 Subject: [PATCH 1/3] fix(querybuildertypesv5): round-trip unset stepInterval and empty field-key values (#12171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(querybuildertypesv5): omit unset stepInterval on the wire Step is a struct (struct{ time.Duration }), so omitempty had no effect — an unset stepInterval serialized as 0 instead of being omitted, so a typed client reading a query back saw a 0 it never sent (create -> GET drift). Tag stepInterval with ,omitzero so an unset value is dropped while a set value still serializes (as seconds), on all three sites: builder query, trace-operator, and secondary aggregation. Schema-invisible (no OpenAPI / client change). source and the metric enums were already handled in #12164. * fix(telemetrytypes): round-trip empty fieldContext/fieldDataType on field keys A TelemetryFieldKey can deliberately leave fieldContext/fieldDataType empty to match across any context / data type, but ,omitzero dropped that empty value on serialize, so a typed client that sent "" read it back as absent (create -> GET drift). Make both fields always serialize and add the empty member to their Enum()s so "" is a valid schema value that round-trips verbatim — the same approach #12164 used for source. Signal keeps ,omitzero: its empty value is invalid for the query/variable signal contexts that share the enum (adding "" there breaks those consumers), and a field key's signal is not deliberately empty. Regenerate the OpenAPI spec + client (fieldContext/fieldDataType enums gain "") and update the ScalarData marshal test (column keys now echo the fields). * fix(telemetrytypes): round-trip empty signal on field keys Extend the field-key round-trip fix to Signal: add the empty member to Signal.Enum() and make TelemetryFieldKey.Signal always serialize, so an empty ("any") field-key signal round-trips as a valid value instead of being dropped — matching the fieldContext/fieldDataType treatment. The Signal enum is shared with query/variable signals, where "" is invalid. Narrow the frontend's TelemetrySignal type to logs/traces/metrics (so the variable/panel signal selectors stay exhaustive), label the empty member in the panel type switcher's map, and fold an empty drilldown signal into "all". Regenerate the OpenAPI spec + client (Signal enum gains ""), and update the ScalarData marshal test and the querierlogs aggregation label assertions to include the now-serialized empty signal. --- docs/api/openapi.yml | 3 ++ .../api/generated/services/sigNoz.schemas.ts | 3 ++ .../Variables/variableFormModel.ts | 8 +++- .../ConfigPane/PanelTypeSwitcher/utils.ts | 3 ++ .../hooks/useDrilldownDashboardVariables.tsx | 3 +- .../querybuildertypesv5/builder_elements.go | 2 +- .../querybuildertypesv5/builder_query.go | 2 +- .../querybuildertypesv5/builder_query_test.go | 37 +++++++++++++++++++ .../querybuildertypesv5/resp_test.go | 8 ++-- .../querybuildertypesv5/trace_operator.go | 2 +- pkg/types/telemetrytypes/field.go | 16 +++++--- pkg/types/telemetrytypes/field_context.go | 1 + pkg/types/telemetrytypes/field_datatype.go | 1 + pkg/types/telemetrytypes/signal.go | 1 + .../tests/querierlogs/02_aggregation.py | 6 +++ 15 files changed, 81 insertions(+), 15 deletions(-) diff --git a/docs/api/openapi.yml b/docs/api/openapi.yml index 03b6c021494..ba071cedf4d 100644 --- a/docs/api/openapi.yml +++ b/docs/api/openapi.yml @@ -8558,6 +8558,7 @@ components: - resource - attribute - body + - "" type: string TelemetrytypesFieldDataType: enum: @@ -8566,6 +8567,7 @@ components: - float64 - int64 - number + - "" type: string TelemetrytypesGettableFieldKeys: properties: @@ -8597,6 +8599,7 @@ components: - traces - logs - metrics + - "" type: string TelemetrytypesSource: enum: diff --git a/frontend/src/api/generated/services/sigNoz.schemas.ts b/frontend/src/api/generated/services/sigNoz.schemas.ts index c60b0bd1906..e4d08510101 100644 --- a/frontend/src/api/generated/services/sigNoz.schemas.ts +++ b/frontend/src/api/generated/services/sigNoz.schemas.ts @@ -3479,6 +3479,7 @@ export enum TelemetrytypesFieldContextDTO { resource = 'resource', attribute = 'attribute', body = 'body', + '' = '', } export enum TelemetrytypesFieldDataTypeDTO { string = 'string', @@ -3486,11 +3487,13 @@ export enum TelemetrytypesFieldDataTypeDTO { float64 = 'float64', int64 = 'int64', number = 'number', + '' = '', } export enum TelemetrytypesSignalDTO { traces = 'traces', logs = 'logs', metrics = 'metrics', + '' = '', } export interface Querybuildertypesv5GroupByKeyDTO { /** diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel.ts index 37a6f1d5634..a5a2dcacad2 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel.ts +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel.ts @@ -16,7 +16,13 @@ import { sortBy } from 'lodash-es'; export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC'; /** Telemetry signal — the generated enum (traces / logs / metrics). */ -export type TelemetrySignal = TelemetrytypesSignalDTO; +// A query/variable signal is only logs/traces/metrics. TelemetrytypesSignalDTO +// also carries the empty "any" value used on field keys, which is not a valid +// query/variable signal, so exclude it here. +export type TelemetrySignal = + | TelemetrytypesSignalDTO.logs + | TelemetrytypesSignalDTO.traces + | TelemetrytypesSignalDTO.metrics; /** * Signal selected in the dynamic-variable editor. `'all'` is UI-only (the diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/utils.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/utils.ts index a004515844c..8a95c4d8efd 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/utils.ts +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/utils.ts @@ -17,6 +17,9 @@ const SIGNAL_LABEL: Record = { [TelemetrytypesSignalDTO.logs]: 'logs', [TelemetrytypesSignalDTO.traces]: 'traces', [TelemetrytypesSignalDTO.metrics]: 'metrics', + // The empty "any" signal only appears on field keys, never on a panel query; + // mapped for exhaustiveness. + [TelemetrytypesSignalDTO['']]: '', }; /** diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDrilldownDashboardVariables.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDrilldownDashboardVariables.tsx index f8746cfd7c8..c1b726004f4 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDrilldownDashboardVariables.tsx +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDrilldownDashboardVariables.tsx @@ -105,7 +105,8 @@ export function useDrilldownDashboardVariables({ type: 'DYNAMIC', multiSelect: true, dynamicAttribute: fieldName, - dynamicSignal: signal ?? DYNAMIC_SIGNAL_ALL, + // `||` (not `??`): an empty "any" signal maps to All, same as unset. + dynamicSignal: signal || DYNAMIC_SIGNAL_ALL, }; try { await patchAsync( diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/builder_elements.go b/pkg/types/querybuildertypes/querybuildertypesv5/builder_elements.go index bac993d8faa..83929c475fe 100644 --- a/pkg/types/querybuildertypes/querybuildertypesv5/builder_elements.go +++ b/pkg/types/querybuildertypes/querybuildertypesv5/builder_elements.go @@ -613,7 +613,7 @@ func (o OrderBy) Copy() OrderBy { type SecondaryAggregation struct { // stepInterval of the query // if not set, it will use the step interval of the primary aggregation - StepInterval Step `json:"stepInterval,omitempty"` + StepInterval Step `json:"stepInterval,omitzero"` // expression to aggregate. example: count(), sum(item_price), countIf(day > 10) Expression string `json:"expression"` // if any, it will be used as the alias of the aggregation in the result diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/builder_query.go b/pkg/types/querybuildertypes/querybuildertypesv5/builder_query.go index 68eba977ef1..ff27436cd8c 100644 --- a/pkg/types/querybuildertypes/querybuildertypesv5/builder_query.go +++ b/pkg/types/querybuildertypes/querybuildertypesv5/builder_query.go @@ -16,7 +16,7 @@ type QueryBuilderQuery[T any] struct { Name string `json:"name"` // stepInterval of the query - StepInterval Step `json:"stepInterval,omitempty"` + StepInterval Step `json:"stepInterval,omitzero"` // signal to query Signal telemetrytypes.Signal `json:"signal,omitempty"` diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/builder_query_test.go b/pkg/types/querybuildertypes/querybuildertypesv5/builder_query_test.go index b4f4bf72055..866d75253ea 100644 --- a/pkg/types/querybuildertypes/querybuildertypesv5/builder_query_test.go +++ b/pkg/types/querybuildertypes/querybuildertypesv5/builder_query_test.go @@ -709,3 +709,40 @@ func TestQueryBuilderQuery_MetricAggregation_MarshalJSONEnumRoundTrip(t *testing }) } } + +// stepInterval is a struct-backed Step, so omitempty had no effect and an unset +// value serialized as 0; ,omitzero omits it when unset while still echoing a set +// value (as seconds), keeping the create -> GET round-trip stable. +func TestQueryBuilderQuery_StepInterval_MarshalRoundTrip(t *testing.T) { + testCases := []struct { + name string + query QueryBuilderQuery[LogAggregation] + present []string + absent []string + }{ + { + name: "UnsetStepIntervalIsOmitted", + query: QueryBuilderQuery[LogAggregation]{Name: "A", Signal: telemetrytypes.SignalLogs}, + absent: []string{`"stepInterval"`}, + }, + { + name: "SetStepIntervalIsSerializedAsSeconds", + query: QueryBuilderQuery[LogAggregation]{Name: "A", Signal: telemetrytypes.SignalLogs, StepInterval: Step{60 * time.Second}}, + present: []string{`"stepInterval":60`}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + expected, err := json.Marshal(testCase.query) + require.NoError(t, err) + + for _, fragment := range testCase.present { + assert.Contains(t, string(expected), fragment) + } + for _, fragment := range testCase.absent { + assert.NotContains(t, string(expected), fragment) + } + }) + } +} diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/resp_test.go b/pkg/types/querybuildertypes/querybuildertypesv5/resp_test.go index 29b459154fa..9ffa373d290 100644 --- a/pkg/types/querybuildertypes/querybuildertypesv5/resp_test.go +++ b/pkg/types/querybuildertypes/querybuildertypesv5/resp_test.go @@ -130,7 +130,7 @@ func TestScalarData_MarshalJSON(t *testing.T) { {4.0, 5.0, 6.0}, }, }, - expected: `{"queryName":"test_query","columns":[{"name":"value","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,2,3],[4,5,6]]}`, + expected: `{"queryName":"test_query","columns":[{"name":"value","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,2,3],[4,5,6]]}`, }, { name: "scalar data with NaN", @@ -149,7 +149,7 @@ func TestScalarData_MarshalJSON(t *testing.T) { {math.Inf(1), 5.0, math.Inf(-1)}, }, }, - expected: `{"queryName":"test_query","columns":[{"name":"value","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,"NaN",3],["Inf",5,"-Inf"]]}`, + expected: `{"queryName":"test_query","columns":[{"name":"value","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,"NaN",3],["Inf",5,"-Inf"]]}`, }, { name: "scalar data with mixed types", @@ -168,7 +168,7 @@ func TestScalarData_MarshalJSON(t *testing.T) { {nil, math.Inf(1), 3.14, false}, }, }, - expected: `{"queryName":"test_query","columns":[{"name":"mixed","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[["string",42,"NaN",true],[null,"Inf",3.14,false]]}`, + expected: `{"queryName":"test_query","columns":[{"name":"mixed","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[["string",42,"NaN",true],[null,"Inf",3.14,false]]}`, }, { name: "scalar data with nested structures", @@ -189,7 +189,7 @@ func TestScalarData_MarshalJSON(t *testing.T) { }, }, }, - expected: `{"queryName":"test_query","columns":[{"name":"nested","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[{"count":10,"value":"NaN"},[1,"Inf",3]]]}`, + expected: `{"queryName":"test_query","columns":[{"name":"nested","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[{"count":10,"value":"NaN"},[1,"Inf",3]]]}`, }, { name: "empty scalar data", diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/trace_operator.go b/pkg/types/querybuildertypes/querybuildertypesv5/trace_operator.go index c9080b9617f..0c0293fdc66 100644 --- a/pkg/types/querybuildertypes/querybuildertypesv5/trace_operator.go +++ b/pkg/types/querybuildertypes/querybuildertypesv5/trace_operator.go @@ -51,7 +51,7 @@ type QueryBuilderTraceOperator struct { Order []OrderBy `json:"order,omitzero"` Aggregations []TraceAggregation `json:"aggregations,omitzero"` - StepInterval Step `json:"stepInterval,omitempty"` + StepInterval Step `json:"stepInterval,omitzero"` GroupBy []GroupByKey `json:"groupBy,omitzero"` // having clause to apply to the aggregated query results diff --git a/pkg/types/telemetrytypes/field.go b/pkg/types/telemetrytypes/field.go index 303e22113b6..1cd45c544ff 100644 --- a/pkg/types/telemetrytypes/field.go +++ b/pkg/types/telemetrytypes/field.go @@ -30,12 +30,16 @@ const ( ) type TelemetryFieldKey struct { - Name string `json:"name" validate:"required" required:"true"` - Description string `json:"description,omitempty"` - Unit string `json:"unit,omitempty"` - Signal Signal `json:"signal,omitzero"` - FieldContext FieldContext `json:"fieldContext,omitzero"` - FieldDataType FieldDataType `json:"fieldDataType,omitzero"` + Name string `json:"name" validate:"required" required:"true"` + Description string `json:"description,omitempty"` + Unit string `json:"unit,omitempty"` + // signal/fieldContext/fieldDataType always serialize (empty included): the empty + // value is a first-class "unspecified / any" selection a client can set, so it + // must round-trip verbatim rather than be dropped. Their Enum()s include the + // empty member so the "" is a valid schema value. + Signal Signal `json:"signal"` + FieldContext FieldContext `json:"fieldContext"` + FieldDataType FieldDataType `json:"fieldDataType"` JSONPlan JSONAccessPlan `json:"-"` Indexes []TelemetryFieldKeySkipIndex `json:"-"` diff --git a/pkg/types/telemetrytypes/field_context.go b/pkg/types/telemetrytypes/field_context.go index b1773897ebb..4577383e25d 100644 --- a/pkg/types/telemetrytypes/field_context.go +++ b/pkg/types/telemetrytypes/field_context.go @@ -185,5 +185,6 @@ func (FieldContext) Enum() []any { FieldContextAttribute, // FieldContextEvent, FieldContextBody, + FieldContextUnspecified, } } diff --git a/pkg/types/telemetrytypes/field_datatype.go b/pkg/types/telemetrytypes/field_datatype.go index 7cbe8af47c6..11cbd76cf9c 100644 --- a/pkg/types/telemetrytypes/field_datatype.go +++ b/pkg/types/telemetrytypes/field_datatype.go @@ -190,6 +190,7 @@ func (FieldDataType) Enum() []any { FieldDataTypeFloat64, FieldDataTypeInt64, FieldDataTypeNumber, + FieldDataTypeUnspecified, // FieldDataTypeArrayString, // FieldDataTypeArrayFloat64, // FieldDataTypeArrayBool, diff --git a/pkg/types/telemetrytypes/signal.go b/pkg/types/telemetrytypes/signal.go index 15edfc362b2..6150a31a442 100644 --- a/pkg/types/telemetrytypes/signal.go +++ b/pkg/types/telemetrytypes/signal.go @@ -19,5 +19,6 @@ func (Signal) Enum() []any { SignalTraces, SignalLogs, SignalMetrics, + SignalUnspecified, } } diff --git a/tests/integration/tests/querierlogs/02_aggregation.py b/tests/integration/tests/querierlogs/02_aggregation.py index 4e8154541e7..892f7df4bed 100644 --- a/tests/integration/tests/querierlogs/02_aggregation.py +++ b/tests/integration/tests/querierlogs/02_aggregation.py @@ -390,6 +390,9 @@ def test_logs_time_series_count( { "key": { "name": "host.name", + "signal": "", + "fieldContext": "", + "fieldDataType": "", }, "value": "linux-001", } @@ -411,6 +414,9 @@ def test_logs_time_series_count( { "key": { "name": "host.name", + "signal": "", + "fieldContext": "", + "fieldDataType": "", }, "value": "linux-000", } From 9d4349c9d19f94c5a41541bb040a38319b73f438 Mon Sep 17 00:00:00 2001 From: Tushar Vats Date: Mon, 20 Jul 2026 22:27:39 +0530 Subject: [PATCH 2/3] fix(querybuilder): resolve data-type collisions for numeric intrinsic columns (#12176) A span/log query that aggregates or filters a numeric intrinsic column (e.g. duration_nano) failed with ClickHouse NO_COMMON_TYPE (386) when a same-named, type-consistent attribute existed in the metadata: field-key resolution unioned the intrinsic column with the attribute into a multiIf/OR whose branches had incompatible types (UInt64 intrinsic vs Float64 attribute). This broke /api/v1/span_percentile on affected tenants. - fallback_expr: DataTypeCollisionHandledFieldName now handles the unspecified data type in the projection/aggregation path (operator Unknown), coercing the column so collision multiIf branches share a supertype. Comparison contexts are left bare so the column index stays usable. - telemetrytraces/condition_builder: run collision handling for duration_nano (so a same-named string attribute is cast in comparisons) and coerce numeric duration values to int64, keeping the intrinsic comparison bare/index-friendly while preserving the duration-string QoL parsing. - tests: add a type-consistent "collision" trace_noise variant; cover it in the percentile aggregation test and add a duration_nano QoL filter regression test. --- pkg/querybuilder/fallback_expr.go | 9 +++ pkg/telemetrytraces/condition_builder.go | 23 +++---- pkg/telemetrytraces/stmt_builder_test.go | 2 +- .../trace_operator_cte_builder_test.go | 2 +- tests/fixtures/traces.py | 21 +++++- .../queriertraces/11_aggregation_depth.py | 64 ++++++++++++++++++- 6 files changed, 104 insertions(+), 17 deletions(-) diff --git a/pkg/querybuilder/fallback_expr.go b/pkg/querybuilder/fallback_expr.go index 768d6d7cea9..35d4a724197 100644 --- a/pkg/querybuilder/fallback_expr.go +++ b/pkg/querybuilder/fallback_expr.go @@ -181,6 +181,15 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va // dynamic array elements will be default casted to string tblFieldName, value = castString(tblFieldName), toStrings(v) } + case telemetrytypes.FieldDataTypeUnspecified: + if operator == qbtypes.FilterOperatorUnknown { + switch value.(type) { + case float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number: + tblFieldName = accurateCastFloat(tblFieldName) + case string: + tblFieldName = castString(tblFieldName) + } + } } return tblFieldName, value } diff --git a/pkg/telemetrytraces/condition_builder.go b/pkg/telemetrytraces/condition_builder.go index 82355c89dc8..d7ae558ac4c 100644 --- a/pkg/telemetrytraces/condition_builder.go +++ b/pkg/telemetrytraces/condition_builder.go @@ -49,23 +49,24 @@ func (c *conditionBuilder) conditionFor( // TODO(srikanthccv): maybe extend this to every possible attribute if key.Name == "duration_nano" || key.Name == "durationNano" { // QoL improvement - if strDuration, ok := value.(string); ok { - duration, err := time.ParseDuration(strDuration) - if err == nil { + switch v := value.(type) { + case string: + if duration, err := time.ParseDuration(v); err == nil { value = duration.Nanoseconds() + } else if f, err := strconv.ParseFloat(v, 64); err == nil { + value = int64(f) } else { - duration, err := strconv.ParseFloat(strDuration, 64) - if err == nil { - value = duration - } else { - return "", errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid duration value: %s", strDuration) - } + return "", errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid duration value: %s", v) } + case float64: + value = int64(v) + case float32: + value = int64(v) } - } else { - fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator) } + fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator) + // regular operators switch operator { // regular operators diff --git a/pkg/telemetrytraces/stmt_builder_test.go b/pkg/telemetrytraces/stmt_builder_test.go index a5231da4415..b85c4687e9a 100644 --- a/pkg/telemetrytraces/stmt_builder_test.go +++ b/pkg/telemetrytraces/stmt_builder_test.go @@ -367,7 +367,7 @@ func TestStatementBuilder(t *testing.T) { }, }, expected: qbtypes.Statement{ - Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(response_status_code <> '', response_status_code, NULL)) AS `__GROUP_BY_KEY_0_responseStatusCode`, quantile(0.90)(multiIf(duration_nano <> 0, duration_nano, NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_responseStatusCode` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(response_status_code <> '', response_status_code, NULL)) AS `__GROUP_BY_KEY_0_responseStatusCode`, quantile(0.90)(multiIf(duration_nano <> 0, duration_nano, NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_responseStatusCode`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_responseStatusCode` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_responseStatusCode`", + Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(response_status_code <> '', response_status_code, NULL)) AS `__GROUP_BY_KEY_0_responseStatusCode`, quantile(0.90)(multiIf(duration_nano <> 0, accurateCastOrNull(duration_nano, 'Float64'), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_responseStatusCode` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(response_status_code <> '', response_status_code, NULL)) AS `__GROUP_BY_KEY_0_responseStatusCode`, quantile(0.90)(multiIf(duration_nano <> 0, accurateCastOrNull(duration_nano, 'Float64'), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_responseStatusCode`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_responseStatusCode` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_responseStatusCode`", Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)}, }, expectedErr: nil, diff --git a/pkg/telemetrytraces/trace_operator_cte_builder_test.go b/pkg/telemetrytraces/trace_operator_cte_builder_test.go index ba4b1effaf0..b7185115eb4 100644 --- a/pkg/telemetrytraces/trace_operator_cte_builder_test.go +++ b/pkg/telemetrytraces/trace_operator_cte_builder_test.go @@ -343,7 +343,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) { }, }, expected: qbtypes.Statement{ - Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND toFloat64(response_status_code) < ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, avg(multiIf(duration_nano <> 0, duration_nano, mapContains(attributes_number, 'duration_nano'), toFloat64(attributes_number['duration_nano']), NULL)) AS __result_0 FROM A_AND_B GROUP BY `service.name` ORDER BY __result_0 desc SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000", + Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND toFloat64(response_status_code) < ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, avg(multiIf(duration_nano <> 0, accurateCastOrNull(duration_nano, 'Float64'), mapContains(attributes_number, 'duration_nano'), toFloat64(attributes_number['duration_nano']), NULL)) AS __result_0 FROM A_AND_B GROUP BY `service.name` ORDER BY __result_0 desc SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000", Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), float64(400)}, }, expectedErr: nil, diff --git a/tests/fixtures/traces.py b/tests/fixtures/traces.py index 34bece5f62a..36f8425f09f 100644 --- a/tests/fixtures/traces.py +++ b/tests/fixtures/traces.py @@ -968,12 +968,27 @@ def remove_traces_ttl_and_storage_settings(signoz: types.SigNoz): "http_method": "corrupt_data", } +# A TYPE-CONSISTENT collision, distinct from CORRUPT_* (whose wrong-type values are +# dropped by field-key resolution): a numeric span attribute named `duration_nano` +# shares both the name AND a compatible type with the intrinsic duration_nano (UInt64) +# column, so resolution unions it with the column into a multiIf. This exercises the +# collision path that regressed with ClickHouse NO_COMMON_TYPE (386) — the intrinsic +# column must still win. Kept out of CORRUPT_ATTRIBUTES because a type-consistent +# collision changes raw-select output (the multiIf stringifies the value), which the +# list tests assert against; only aggregation/filter tests opt into this variant. +COLLISION_ATTRIBUTES: dict[str, Any] = { + "duration_nano": 1.0, # numeric attr vs the numeric intrinsic (type-consistent) +} + def trace_noise(variant: str) -> tuple[dict[str, Any], dict[str, Any]]: """(extra_attributes, extra_resources) to merge into every span a traces test seeds, keyed by - variant. "clean" adds nothing; "corrupt" injects colliding intrinsic/calculated field names and - an attribute/resource collision so a test parametrized over both doubles as a - collision-robustness check. Returns fresh dicts so callers can mutate them safely.""" + variant. "clean" adds nothing; "corrupt" injects wrong-type colliding intrinsic/calculated field + names (dropped by resolution, so results are unchanged); "collision" injects a type-consistent + numeric duration_nano attribute that unions into a multiIf, exercising collision resolution on + aggregation/filter paths. Returns fresh dicts so callers can mutate them safely.""" if variant == "clean": return {}, {} + if variant == "collision": + return dict(COLLISION_ATTRIBUTES), {} return dict(CORRUPT_ATTRIBUTES), dict(CORRUPT_RESOURCES) diff --git a/tests/integration/tests/queriertraces/11_aggregation_depth.py b/tests/integration/tests/queriertraces/11_aggregation_depth.py index 11388f1364f..35cfeae0671 100644 --- a/tests/integration/tests/queriertraces/11_aggregation_depth.py +++ b/tests/integration/tests/queriertraces/11_aggregation_depth.py @@ -26,7 +26,7 @@ # intrinsic column. -@pytest.mark.parametrize("noise", ["clean", "corrupt"]) +@pytest.mark.parametrize("noise", ["clean", "corrupt", "collision"]) def test_traces_aggregate_percentiles( signoz: types.SigNoz, create_user_admin: None, # pylint: disable=unused-argument @@ -41,6 +41,10 @@ def test_traces_aggregate_percentiles( Tests: p25 / p50 / p90 / p95 / p99 over duration_nano match numpy's linear-interpolated percentiles (ClickHouse quantile() uses the same interpolation for small inputs). + Under the "collision" variant a numeric span attribute named duration_nano unions + with the intrinsic column into a multiIf; the aggregation must still resolve to the + intrinsic column rather than error with ClickHouse NO_COMMON_TYPE (386) — the + regression behind the span_percentile 500. """ extra_attrs, extra_resources = trace_noise(noise) now = datetime.now(tz=UTC).replace(second=0, microsecond=0) @@ -249,3 +253,61 @@ def test_traces_aggregate_having_breadth( assert response.status_code == HTTPStatus.OK, response.text data = get_scalar_table_data(response.json()) assert {row[0] for row in data} == expected_services + + +@pytest.mark.parametrize( + "duration_filter", + [ + pytest.param("duration_nano > '2s'", id="duration_string"), + pytest.param("duration_nano > 2000000000", id="raw_nanoseconds"), + ], +) +def test_traces_duration_nano_qol_filter_with_collision( + signoz: types.SigNoz, + create_user_admin: None, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], + insert_traces: Callable[[list[Traces]], None], + duration_filter: str, +) -> None: + """ + Setup: + 5 spans (durations 1s..5s) that also carry a same-named `duration_nano` span + attribute (numeric on some, string on others). + + Tests the duration_nano QoL filter — as a duration string ('2s') and as raw + nanoseconds — resolves to the intrinsic column despite the collision: it parses the + duration, filters on the real durations (3 spans exceed 2s), and doesn't error with + ClickHouse NO_COMMON_TYPE (386). The string attribute is cast; the intrinsic column + stays a bare comparison. + """ + now = datetime.now(tz=UTC).replace(second=0, microsecond=0) + durations_s = [1, 2, 3, 4, 5] + spans = [ + Traces( + timestamp=now - timedelta(seconds=i + 1), + duration=timedelta(seconds=dur_s), + trace_id=TraceIdGenerator.trace_id(), + span_id=TraceIdGenerator.span_id(), + name=f"dur-qol-{dur_s}", + kind=TracesKind.SPAN_KIND_SERVER, + status_code=TracesStatusCode.STATUS_CODE_OK, + resources={"service.name": "dur-qol-svc"}, + attributes={"duration_nano": 42.0} if i % 2 == 0 else {"duration_nano": "boom"}, + ) + for i, dur_s in enumerate(durations_s) + ] + insert_traces(spans) + + token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + + query = build_traces_scalar_query( + aggregations=[build_aggregation("count()", "cnt")], + filter_expression=duration_filter, + ) + response = make_scalar_query_request(signoz, token, now, [query]) + + assert response.status_code == HTTPStatus.OK, response.text + data = get_scalar_table_data(response.json()) + assert len(data) == 1 + # durations 3s/4s/5s exceed 2s -> 3 spans; the collision must not change this. + assert data[0][0] == 3, f"expected 3 spans with duration > 2s, got {data[0]}" From bf0130a9835ae0bf169df810a30a516b5b4b4bce Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Tue, 21 Jul 2026 00:29:34 +0530 Subject: [PATCH 3/3] fix(clickhouseprometheus): resolve __name__ matchers against metric_name and anchor matcher regexes (#12154) The read client reduced every __name__ matcher to metric_name = (empty string when absent), so nameless selectors ({job="api"}), regex names ({__name__=~".+"}), and negations silently returned empty. Map the __name__ matcher onto the metric_name column per matcher type, drop the condition when no __name__ matcher exists, and narrow the samples query by the metric names the series lookup discovered. Matcher regexes are now anchored (^(?:...)$) the way Prometheus compiles them; ClickHouse match() is a substring search, so {job=~"api"} also matched job="api-server" before. The lookup and samples SQL is built with huandu/go-sqlbuilder (ClickHouse flavor) as in telemetrymetrics and the v2 transpiler: the samples query embeds the series lookup as a nested builder, with argument order merged by the builder in render position instead of hand-numbered placeholders. Co-authored-by: Claude Fable 5 --- ee/query-service/rules/manager_test.go | 6 +- .../clickhouseprometheus/capture.go | 12 +- pkg/prometheus/clickhouseprometheus/client.go | 153 +++++++------ .../clickhouseprometheus/client_query_test.go | 208 +++++++++++++----- pkg/query-service/rules/manager_test.go | 4 +- pkg/query-service/rules/prom_rule_test.go | 30 ++- 6 files changed, 262 insertions(+), 151 deletions(-) diff --git a/ee/query-service/rules/manager_test.go b/ee/query-service/rules/manager_test.go index 0008275a0ca..90c53d8da34 100644 --- a/ee/query-service/rules/manager_test.go +++ b/ee/query-service/rules/manager_test.go @@ -240,17 +240,17 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) { mock := mockStore.Mock() // Mock the fingerprint query (for Prometheus label matching) + // args: $1=metric_name (the __name__ matcher maps onto the column) mock.ExpectQuery("SELECT fingerprint, any"). - WithArgs("test_metric", "__name__", "test_metric"). + WithArgs("test_metric"). WillReturnRows(fingerprintRows) // Mock the samples query (for Prometheus metric data) + // args: metric_name IN (discovered names), subquery metric_name, start, end mock.ExpectQuery("SELECT metric_name, fingerprint, unix_milli"). WithArgs( "test_metric", "test_metric", - "__name__", - "test_metric", queryStart, queryEnd, ). diff --git a/pkg/prometheus/clickhouseprometheus/capture.go b/pkg/prometheus/clickhouseprometheus/capture.go index 840f5bcd89a..3c760fe9fe2 100644 --- a/pkg/prometheus/clickhouseprometheus/capture.go +++ b/pkg/prometheus/clickhouseprometheus/capture.go @@ -56,19 +56,21 @@ func (c *captureClient) Read(ctx context.Context, query *prompb.Query, _ bool) ( } } - var metricName string + // Without executing the series lookup, only an exact-name selector's + // metric name is known. + var metricNames []string for _, matcher := range query.Matchers { - if matcher.Name == "__name__" { - metricName = matcher.Value + if matcher.Name == "__name__" && matcher.Type == prompb.LabelMatcher_EQ { + metricNames = []string{matcher.Value} } } // Build the executing path's queries, but only record them. - subQuery, args, err := c.queryToClickhouseQuery(ctx, query, metricName, true) + sub, err := seriesLookupQuery(query, true) if err != nil { return nil, err } - samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricName, subQuery, args) + samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricNames, sub) c.recorder.record(samplesQuery, samplesArgs) return storage.EmptySeriesSet(), nil diff --git a/pkg/prometheus/clickhouseprometheus/client.go b/pkg/prometheus/clickhouseprometheus/client.go index 53a922b1647..628da6c453b 100644 --- a/pkg/prometheus/clickhouseprometheus/client.go +++ b/pkg/prometheus/clickhouseprometheus/client.go @@ -4,8 +4,7 @@ import ( "context" "fmt" "math" - "strconv" - "strings" + "sort" "sync" "github.com/SigNoz/signoz/pkg/errors" @@ -15,6 +14,7 @@ import ( "github.com/SigNoz/signoz/pkg/types/instrumentationtypes" "github.com/SigNoz/signoz/pkg/types/telemetrytypes" "github.com/cespare/xxhash/v2" + "github.com/huandu/go-sqlbuilder" promValue "github.com/prometheus/prometheus/model/value" "github.com/prometheus/prometheus/prompb" "github.com/prometheus/prometheus/storage" @@ -56,19 +56,13 @@ func (client *client) Read(ctx context.Context, query *prompb.Query, sortSeries } } - var metricName string - for _, matcher := range query.Matchers { - if matcher.Name == "__name__" { - metricName = matcher.Value - } - } - - clickhouseQuery, args, err := client.queryToClickhouseQuery(ctx, query, metricName, false) + lookup, err := seriesLookupQuery(query, false) if err != nil { return nil, err } + lookupSQL, lookupArgs := lookup.BuildWithFlavor(sqlbuilder.ClickHouse) - fingerprints, err := client.getFingerprintsFromClickhouseQuery(ctx, clickhouseQuery, args) + fingerprints, metricNames, err := client.getFingerprintsFromClickhouseQuery(ctx, lookupSQL, lookupArgs) if err != nil { return nil, err } @@ -76,13 +70,14 @@ func (client *client) Read(ctx context.Context, query *prompb.Query, sortSeries return remote.FromQueryResult(sortSeries, new(prompb.QueryResult)), nil } - clickhouseSubQuery, args, err := client.queryToClickhouseQuery(ctx, query, metricName, true) + sub, err := seriesLookupQuery(query, true) if err != nil { return nil, err } + samplesSQL, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricNames, sub) res := new(prompb.QueryResult) - timeseries, err := client.querySamples(ctx, int64(query.StartTimestampMs), int64(query.EndTimestampMs), fingerprints, metricName, clickhouseSubQuery, args) + timeseries, err := client.querySamples(ctx, samplesSQL, samplesArgs, fingerprints) if err != nil { return nil, err } @@ -126,86 +121,115 @@ func (c *client) ReadMultiple(ctx context.Context, queries []*prompb.Query, sort return storage.NewMergeSeriesSet(sets, 0, storage.ChainedSeriesMerge), nil } -func (client *client) queryToClickhouseQuery(_ context.Context, query *prompb.Query, metricName string, subQuery bool) (string, []any, error) { - var clickHouseQuery string - var conditions []string - var argCount = 0 - var selectString = "fingerprint, any(labels)" +// anchorRegex makes a pattern fully anchored, the way Prometheus compiles +// matcher regexes; ClickHouse's match() would otherwise substring-match. +func anchorRegex(pattern string) string { + return "^(?:" + pattern + ")$" +} + +// seriesLookupQuery builds the time-series lookup. It returns a builder so +// the samples query can embed it as a subquery with the args merged in +// render order by the builder instead of hand-numbered placeholders. +func seriesLookupQuery(query *prompb.Query, subQuery bool) (*sqlbuilder.SelectBuilder, error) { + sb := sqlbuilder.NewSelectBuilder() if subQuery { - argCount = 1 - selectString = "fingerprint" + sb.Select("fingerprint") + } else { + sb.Select("fingerprint", "any(labels)") } start, end, tableName := getStartAndEndAndTableName(query.StartTimestampMs, query.EndTimestampMs) + sb.From(databaseName + "." + tableName) - var args []any - conditions = append(conditions, fmt.Sprintf("metric_name = $%d", argCount+1)) - conditions = append(conditions, "temporality IN ['Cumulative', 'Unspecified']") + sb.Where("temporality IN ['Cumulative', 'Unspecified']") // Inclusive upper bound: registration rows are hour-floored by the // exporter, so a series first registered in the hour starting exactly at // `end` would otherwise be invisible while its samples (<= end) are in // range. - conditions = append(conditions, fmt.Sprintf("unix_milli >= %d AND unix_milli <= %d", start, end)) + sb.Where(fmt.Sprintf("unix_milli >= %d AND unix_milli <= %d", start, end)) - args = append(args, metricName) for _, m := range query.Matchers { + if m.Name == "__name__" { + // __name__ maps onto the metric_name column per matcher type; + // reducing regex/negated/absent name matchers to one equality + // made such selectors silently return empty. + switch m.Type { + case prompb.LabelMatcher_EQ: + sb.Where(sb.E("metric_name", m.Value)) + case prompb.LabelMatcher_NEQ: + sb.Where(sb.NE("metric_name", m.Value)) + case prompb.LabelMatcher_RE: + sb.Where(fmt.Sprintf("match(metric_name, %s)", sb.Var(anchorRegex(m.Value)))) + case prompb.LabelMatcher_NRE: + sb.Where(fmt.Sprintf("not match(metric_name, %s)", sb.Var(anchorRegex(m.Value)))) + default: + return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String()) + } + continue + } switch m.Type { case prompb.LabelMatcher_EQ: - conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, $%d) = $%d", argCount+2, argCount+3)) + sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) = %s", sb.Var(m.Name), sb.Var(m.Value))) case prompb.LabelMatcher_NEQ: - conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, $%d) != $%d", argCount+2, argCount+3)) + sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) != %s", sb.Var(m.Name), sb.Var(m.Value))) case prompb.LabelMatcher_RE: - conditions = append(conditions, fmt.Sprintf("match(JSONExtractString(labels, $%d), $%d)", argCount+2, argCount+3)) + sb.Where(fmt.Sprintf("match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value)))) case prompb.LabelMatcher_NRE: - conditions = append(conditions, fmt.Sprintf("not match(JSONExtractString(labels, $%d), $%d)", argCount+2, argCount+3)) + sb.Where(fmt.Sprintf("not match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value)))) default: - return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String()) + return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String()) } - args = append(args, m.Name, m.Value) - argCount += 2 } - whereClause := strings.Join(conditions, " AND ") - - clickHouseQuery = fmt.Sprintf(`SELECT %s FROM %s.%s WHERE %s GROUP BY fingerprint`, selectString, databaseName, tableName, whereClause) - - return clickHouseQuery, args, nil + sb.GroupBy("fingerprint") + return sb, nil } -func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, query string, args []any) (map[uint64][]prompb.Label, error) { +func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, query string, args []any) (map[uint64][]prompb.Label, []string, error) { ctx = client.withClickhousePrometheusContext(ctx, "getFingerprintsFromClickhouseQuery") rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...) if err != nil { - return nil, err + return nil, nil, err } defer rows.Close() fingerprints := make(map[uint64][]prompb.Label) + nameSet := make(map[string]struct{}) var fingerprint uint64 var labelString string for rows.Next() { if err = rows.Scan(&fingerprint, &labelString); err != nil { - return nil, err + return nil, nil, err } - labels, _, err := unmarshalLabels(labelString) + labels, metricName, err := unmarshalLabels(labelString) if err != nil { - return nil, err + return nil, nil, err } fingerprints[fingerprint] = labels + if metricName != "" { + nameSet[metricName] = struct{}{} + } } if err := rows.Err(); err != nil { - return nil, err + return nil, nil, err + } + + metricNames := make([]string, 0, len(nameSet)) + for name := range nameSet { + metricNames = append(metricNames, name) } + sort.Strings(metricNames) - return fingerprints, nil + return fingerprints, metricNames, nil } -// buildSamplesQuery renders the samples SQL (and args) that fetches data -// points for the series selected by subQuery. +// buildSamplesQuery renders the samples SQL for the series selected by +// subQuery. The metric_name condition exists only for primary-key pruning; +// the fingerprint filter already selects the right rows. // // Time bounds are inclusive on both ends because that is Prometheus's // storage contract: Select(mint, maxt) returns [start, end] and the engine @@ -215,27 +239,29 @@ func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, qu // its own model — toStartOfInterval buckets covering [t, t+step), where a // sample at `end` falls in an unrendered bucket and end-exclusive ranges // tile exactly across cached time slices. -func buildSamplesQuery(start int64, end int64, metricName string, subQuery string, args []any) (string, []any) { - argCount := len(args) - - query := fmt.Sprintf(` - SELECT metric_name, fingerprint, unix_milli, value, flags - FROM %s.%s - WHERE metric_name = $1 AND fingerprint GLOBAL IN (%s) AND unix_milli >= $%s AND unix_milli <= $%s ORDER BY fingerprint, unix_milli;`, - databaseName, distributedSamplesV4, subQuery, strconv.Itoa(argCount+2), strconv.Itoa(argCount+3)) - query = strings.TrimSpace(query) - - allArgs := append([]any{metricName}, args...) - allArgs = append(allArgs, start, end) - return query, allArgs +func buildSamplesQuery(start int64, end int64, metricNames []string, sub *sqlbuilder.SelectBuilder) (string, []any) { + sb := sqlbuilder.NewSelectBuilder() + sb.Select("metric_name", "fingerprint", "unix_milli", "value", "flags") + sb.From(databaseName + "." + distributedSamplesV4) + + if len(metricNames) > 0 { + names := make([]any, len(metricNames)) + for i, name := range metricNames { + names[i] = name + } + sb.Where(sb.In("metric_name", names...)) + } + sb.Where(fmt.Sprintf("fingerprint GLOBAL IN (%s)", sb.Var(sub))) + sb.Where(sb.GTE("unix_milli", start), sb.LTE("unix_milli", end)) + sb.OrderBy("fingerprint", "unix_milli") + + return sb.BuildWithFlavor(sqlbuilder.ClickHouse) } -func (client *client) querySamples(ctx context.Context, start int64, end int64, fingerprints map[uint64][]prompb.Label, metricName string, subQuery string, args []any) ([]*prompb.TimeSeries, error) { +func (client *client) querySamples(ctx context.Context, query string, args []any, fingerprints map[uint64][]prompb.Label) ([]*prompb.TimeSeries, error) { ctx = client.withClickhousePrometheusContext(ctx, "querySamples") - query, allArgs := buildSamplesQuery(start, end, metricName, subQuery, args) - - rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, allArgs...) + rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...) if err != nil { return nil, err } @@ -243,6 +269,7 @@ func (client *client) querySamples(ctx context.Context, start int64, end int64, var res []*prompb.TimeSeries var ts *prompb.TimeSeries + var metricName string var fingerprint, prevFingerprint uint64 var timestampMs, prevTimestamp int64 var value float64 diff --git a/pkg/prometheus/clickhouseprometheus/client_query_test.go b/pkg/prometheus/clickhouseprometheus/client_query_test.go index 5417f575afb..99748f0944b 100644 --- a/pkg/prometheus/clickhouseprometheus/client_query_test.go +++ b/pkg/prometheus/clickhouseprometheus/client_query_test.go @@ -11,6 +11,7 @@ import ( "github.com/DATA-DOG/go-sqlmock" "github.com/SigNoz/signoz/pkg/telemetrystore" + "github.com/huandu/go-sqlbuilder" "github.com/prometheus/prometheus/prompb" "github.com/stretchr/testify/assert" ) @@ -29,7 +30,7 @@ func TestClient_QuerySamples(t *testing.T) { start int64 end int64 fingerprints map[uint64][]prompb.Label - metricName string + metricNames []string subQuery string args []any setupMock func(mock cmock.ClickConnMockCommon, args ...any) @@ -52,7 +53,7 @@ func TestClient_QuerySamples(t *testing.T) { {Name: "instance", Value: "localhost:9091"}, }, }, - metricName: "cpu_usage", + metricNames: []string{"cpu_usage"}, subQuery: "SELECT metric_name, fingerprint, unix_milli, value, flags", expectedTimeSeries: 2, expectError: false, @@ -97,10 +98,10 @@ func TestClient_QuerySamples(t *testing.T) { telemetryStore := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherRegexp) readClient := client{telemetryStore: telemetryStore} if tt.setupMock != nil { - tt.setupMock(telemetryStore.Mock(), tt.metricName, tt.start, tt.end) + tt.setupMock(telemetryStore.Mock(), "cpu_usage", tt.start, tt.end) } - result, err := readClient.querySamples(ctx, tt.start, tt.end, tt.fingerprints, tt.metricName, tt.subQuery, tt.args) + result, err := readClient.querySamples(ctx, tt.subQuery, []any{"cpu_usage", tt.start, tt.end}, tt.fingerprints) if tt.expectError { assert.Error(t, err) @@ -115,6 +116,68 @@ func TestClient_QuerySamples(t *testing.T) { } } +// Regression for the duplicate-series class behind #8563: fingerprints +// sharing one labelset must come back as one merged series, the higher +// fingerprint winning equal timestamps. +func TestClient_QuerySamplesMergesIdenticalLabelSets(t *testing.T) { + ctx := context.Background() + cols := []cmock.ColumnType{ + {Name: "metric_name", Type: "String"}, + {Name: "fingerprint", Type: "UInt64"}, + {Name: "unix_milli", Type: "Int64"}, + {Name: "value", Type: "Float64"}, + {Name: "flags", Type: "UInt32"}, + } + + canary := []prompb.Label{ + {Name: "__name__", Value: "requests"}, + {Name: "group", Value: "canary"}, + } + production := []prompb.Label{ + {Name: "__name__", Value: "requests"}, + {Name: "group", Value: "production"}, + } + fingerprints := map[uint64][]prompb.Label{ + 100: canary, + 200: canary, + 300: production, + } + + telemetryStore := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherRegexp) + // Rows arrive ordered by (fingerprint, unix_milli), matching the SQL. + values := [][]any{ + {"requests", uint64(100), int64(1000), float64(1.0), uint32(0)}, + {"requests", uint64(100), int64(2000), float64(2.0), uint32(0)}, + {"requests", uint64(200), int64(2000), float64(20.0), uint32(0)}, + {"requests", uint64(200), int64(3000), float64(30.0), uint32(0)}, + {"requests", uint64(300), int64(1500), float64(5.0), uint32(0)}, + } + telemetryStore.Mock().ExpectQuery("SELECT metric_name, fingerprint, unix_milli, value, flags"). + WithArgs("requests", int64(1000), int64(3000)). + WillReturnRows(cmock.NewRows(cols, values)) + + readClient := client{telemetryStore: telemetryStore} + result, err := readClient.querySamples(ctx, "SELECT metric_name, fingerprint, unix_milli, value, flags", []any{"requests", int64(1000), int64(3000)}, fingerprints) + require.NoError(t, err) + + assert.Equal(t, []*prompb.TimeSeries{ + { + Labels: canary, + Samples: []prompb.Sample{ + {Timestamp: 1000, Value: 1.0}, + {Timestamp: 2000, Value: 20.0}, + {Timestamp: 3000, Value: 30.0}, + }, + }, + { + Labels: production, + Samples: []prompb.Sample{ + {Timestamp: 1500, Value: 5.0}, + }, + }, + }, result) +} + func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) { cols := []cmock.ColumnType{ {Name: "fingerprint", Type: "UInt64"}, @@ -138,6 +201,7 @@ func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) { args []any setupMock func(m cmock.ClickConnMockCommon, args ...any) want map[uint64][]prompb.Label + wantNames []string wantErr bool }{ { @@ -151,8 +215,8 @@ func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) { setupMock: func(m cmock.ClickConnMockCommon, args ...any) { rows := [][]any{ - {uint64(123), `{"t1":"s1","t2":"s2"}`}, - {uint64(234), `{"t1":"s1","t2":"s2","empty":""}`}, + {uint64(123), `{"__name__":"cpu_usage","t1":"s1","t2":"s2"}`}, + {uint64(234), `{"__name__":"cpu_usage","t1":"s1","t2":"s2","empty":""}`}, } m.ExpectQuery(`SELECT fingerprint,labels`).WithArgs(args...).WillReturnRows( cmock.NewRows(cols, rows), @@ -164,14 +228,17 @@ func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) { // querySamples to merge. want: map[uint64][]prompb.Label{ 123: { + {Name: "__name__", Value: "cpu_usage"}, {Name: "t1", Value: "s1"}, {Name: "t2", Value: "s2"}, }, 234: { + {Name: "__name__", Value: "cpu_usage"}, {Name: "t1", Value: "s1"}, {Name: "t2", Value: "s2"}, }, }, + wantNames: []string{"cpu_usage"}, }, } @@ -189,13 +256,14 @@ func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) { c := client{telemetryStore: store} - got, err := c.getFingerprintsFromClickhouseQuery(ctx, tc.subQuery, tc.args) + got, gotNames, err := c.getFingerprintsFromClickhouseQuery(ctx, tc.subQuery, tc.args) if tc.wantErr { require.Error(t, err) require.Nil(t, got) return } require.NoError(t, err) + assert.Equal(t, tc.wantNames, gotNames, "discovered metric names mismatch") require.Equal(t, len(tc.want), len(got), "fingerprint map length mismatch") for fp, expLabels := range tc.want { gotLabels, ok := got[fp] @@ -210,66 +278,86 @@ func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) { } } -// Regression for the duplicate-series class behind #8563: fingerprints -// sharing one labelset must come back as one merged series, the higher -// fingerprint winning equal timestamps. -func TestClient_QuerySamplesMergesIdenticalLabelSets(t *testing.T) { - ctx := context.Background() - cols := []cmock.ColumnType{ - {Name: "metric_name", Type: "String"}, - {Name: "fingerprint", Type: "UInt64"}, - {Name: "unix_milli", Type: "Int64"}, - {Name: "value", Type: "Float64"}, - {Name: "flags", Type: "UInt32"}, - } - - canary := []prompb.Label{ - {Name: "__name__", Value: "requests"}, - {Name: "group", Value: "canary"}, - } - production := []prompb.Label{ - {Name: "__name__", Value: "requests"}, - {Name: "group", Value: "production"}, +// Regression for nameless/regex-name selectors silently returning empty: +// the old code reduced every __name__ matcher to `metric_name = ` +// (empty string when absent). Regexes must come out anchored — Prometheus +// matcher semantics, while ClickHouse match() substring-matches. +func TestQueryToClickhouseQueryNameMatchers(t *testing.T) { + query := func(matchers ...*prompb.LabelMatcher) *prompb.Query { + return &prompb.Query{StartTimestampMs: 0, EndTimestampMs: 1000, Matchers: matchers} } - fingerprints := map[uint64][]prompb.Label{ - 100: canary, - 200: canary, - 300: production, - } - - telemetryStore := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherRegexp) - // Rows arrive ordered by (fingerprint, unix_milli), matching the SQL. - values := [][]any{ - {"requests", uint64(100), int64(1000), float64(1.0), uint32(0)}, - {"requests", uint64(100), int64(2000), float64(2.0), uint32(0)}, - {"requests", uint64(200), int64(2000), float64(20.0), uint32(0)}, - {"requests", uint64(200), int64(3000), float64(30.0), uint32(0)}, - {"requests", uint64(300), int64(1500), float64(5.0), uint32(0)}, - } - telemetryStore.Mock().ExpectQuery("SELECT metric_name, fingerprint, unix_milli, value, flags"). - WithArgs("requests", int64(1000), int64(3000)). - WillReturnRows(cmock.NewRows(cols, values)) - - readClient := client{telemetryStore: telemetryStore} - result, err := readClient.querySamples(ctx, 1000, 3000, fingerprints, "requests", "SELECT metric_name, fingerprint, unix_milli, value, flags", nil) - require.NoError(t, err) - assert.Equal(t, []*prompb.TimeSeries{ + tests := []struct { + name string + query *prompb.Query + contains []string + absent []string + args []any + }{ { - Labels: canary, - Samples: []prompb.Sample{ - {Timestamp: 1000, Value: 1.0}, - {Timestamp: 2000, Value: 20.0}, - {Timestamp: 3000, Value: 30.0}, - }, + name: "exact name", + query: query(&prompb.LabelMatcher{Type: prompb.LabelMatcher_EQ, Name: "__name__", Value: "cpu_usage"}), + contains: []string{"metric_name = ?"}, + args: []any{"cpu_usage"}, }, { - Labels: production, - Samples: []prompb.Sample{ - {Timestamp: 1500, Value: 5.0}, + name: "regex name is anchored", + query: query(&prompb.LabelMatcher{Type: prompb.LabelMatcher_RE, Name: "__name__", Value: ".+"}), + contains: []string{"match(metric_name, ?)"}, + args: []any{"^(?:.+)$"}, + }, + { + name: "nameless selector has no metric_name condition", + query: query( + &prompb.LabelMatcher{Type: prompb.LabelMatcher_EQ, Name: "job", Value: "api"}, + &prompb.LabelMatcher{Type: prompb.LabelMatcher_NRE, Name: "group", Value: "can.*"}, + ), + contains: []string{ + "JSONExtractString(labels, ?) = ?", + "not match(JSONExtractString(labels, ?), ?)", }, + absent: []string{"metric_name"}, + args: []any{"job", "api", "group", "^(?:can.*)$"}, }, - }, result) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lookup, err := seriesLookupQuery(tt.query, false) + require.NoError(t, err) + sql, args := lookup.BuildWithFlavor(sqlbuilder.ClickHouse) + for _, want := range tt.contains { + assert.Contains(t, sql, want) + } + for _, notWant := range tt.absent { + assert.NotContains(t, sql, notWant) + } + assert.Equal(t, tt.args, args) + }) + } +} + +// The samples query narrows by the metric names the lookup discovered and +// embeds the series lookup as a subquery, the builder merging its args in +// render order. +func TestBuildSamplesQueryMetricNames(t *testing.T) { + sub := sqlbuilder.NewSelectBuilder() + sub.Select("fingerprint") + sub.From("t") + sub.Where(sub.E("k", "v")) + + sql, args := buildSamplesQuery(5, 9, []string{"a_total", "b_total"}, sub) + assert.Contains(t, sql, "metric_name IN (?, ?)") + assert.Contains(t, sql, "fingerprint GLOBAL IN (SELECT fingerprint FROM t WHERE k = ?)") + assert.Contains(t, sql, "unix_milli >= ? AND unix_milli <= ?") + assert.Equal(t, []any{"a_total", "b_total", "v", int64(5), int64(9)}, args) + + sub2 := sqlbuilder.NewSelectBuilder() + sub2.Select("fingerprint") + sub2.From("t") + sql, args = buildSamplesQuery(5, 9, nil, sub2) + assert.NotContains(t, sql, "metric_name IN") + assert.Equal(t, []any{int64(5), int64(9)}, args) } // Hash grouping must stay order-insensitive (stored JSON key order is not diff --git a/pkg/query-service/rules/manager_test.go b/pkg/query-service/rules/manager_test.go index f9c253d9a8d..160ebc8baac 100644 --- a/pkg/query-service/rules/manager_test.go +++ b/pkg/query-service/rules/manager_test.go @@ -237,7 +237,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) { // Mock the fingerprint query (for Prometheus label matching) mock.ExpectQuery("SELECT fingerprint, any"). - WithArgs("test_metric", "__name__", "test_metric"). + WithArgs("test_metric"). WillReturnRows(fingerprintRows) // Mock the samples query (for Prometheus metric data) @@ -245,8 +245,6 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) { WithArgs( "test_metric", "test_metric", - "__name__", - "test_metric", queryStart, queryEnd, ). diff --git a/pkg/query-service/rules/prom_rule_test.go b/pkg/query-service/rules/prom_rule_test.go index bfdf556ae92..d9b03f01c69 100644 --- a/pkg/query-service/rules/prom_rule_test.go +++ b/pkg/query-service/rules/prom_rule_test.go @@ -925,20 +925,18 @@ func TestPromRuleUnitCombinations(t *testing.T) { } samplesRows := cmock.NewRows(samplesCols, samplesData) - // args: $1=metric_name, $2=label_name, $3=label_value + // args: $1=metric_name (the __name__ matcher maps onto the column) telemetryStore.Mock(). ExpectQuery("SELECT fingerprint, any"). - WithArgs("test_metric", "__name__", "test_metric"). + WithArgs("test_metric"). WillReturnRows(fingerprintRows) - // args: $1=metric_name (outer), $2=metric_name (subquery), $3=label_name, $4=label_value, $5=start, $6=end + // args: $1=metric_name IN (discovered names), $2=metric_name (subquery), $3=start, $4=end telemetryStore.Mock(). ExpectQuery("SELECT metric_name, fingerprint, unix_milli"). WithArgs( "test_metric", "test_metric", - "__name__", - "test_metric", queryStart, queryEnd, ). @@ -1063,7 +1061,7 @@ func TestPromRuleNoData(t *testing.T) { // no rows == no data telemetryStore.Mock(). ExpectQuery("SELECT fingerprint, any"). - WithArgs("test_metric", "__name__", "test_metric"). + WithArgs("test_metric"). WillReturnRows(fingerprintRows) promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore) @@ -1273,7 +1271,7 @@ func TestMultipleThresholdPromRule(t *testing.T) { telemetryStore.Mock(). ExpectQuery("SELECT fingerprint, any"). - WithArgs("test_metric", "__name__", "test_metric"). + WithArgs("test_metric"). WillReturnRows(fingerprintRows) telemetryStore.Mock(). @@ -1281,8 +1279,6 @@ func TestMultipleThresholdPromRule(t *testing.T) { WithArgs( "test_metric", "test_metric", - "__name__", - "test_metric", queryStart, queryEnd, ). @@ -1439,12 +1435,12 @@ func TestPromRule_NoData(t *testing.T) { labelsJSON := `{"__name__":"test_metric"}` telemetryStore.Mock(). ExpectQuery("SELECT fingerprint, any"). - WithArgs("test_metric", "__name__", "test_metric"). + WithArgs("test_metric"). WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}})) telemetryStore.Mock(). ExpectQuery("SELECT metric_name, fingerprint, unix_milli"). - WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart, queryEnd). + WithArgs("test_metric", "test_metric", queryStart, queryEnd). WillReturnRows(cmock.NewRows(samplesCols, [][]any{})) promProvider := prometheustest.New( @@ -1575,11 +1571,11 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) { queryStart1, queryEnd1 := calcQueryRange(t1) telemetryStore.Mock(). ExpectQuery("SELECT fingerprint, any"). - WithArgs("test_metric", "__name__", "test_metric"). + WithArgs("test_metric"). WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}})) telemetryStore.Mock(). ExpectQuery("SELECT metric_name, fingerprint, unix_milli"). - WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart1, queryEnd1). + WithArgs("test_metric", "test_metric", queryStart1, queryEnd1). WillReturnRows(cmock.NewRows(samplesCols, [][]any{ // Data points in the past relative to t1 {"test_metric", fingerprint, baseTime.UnixMilli(), 100.0, uint32(0)}, @@ -1591,11 +1587,11 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) { queryStart2, queryEnd2 := calcQueryRange(t2) telemetryStore.Mock(). ExpectQuery("SELECT fingerprint, any"). - WithArgs("test_metric", "__name__", "test_metric"). + WithArgs("test_metric"). WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}})) telemetryStore.Mock(). ExpectQuery("SELECT metric_name, fingerprint, unix_milli"). - WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart2, queryEnd2). + WithArgs("test_metric", "test_metric", queryStart2, queryEnd2). WillReturnRows(cmock.NewRows(samplesCols, [][]any{})) // empty - no data promProvider := prometheustest.New( @@ -1752,11 +1748,11 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) { telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{}) telemetryStore.Mock(). ExpectQuery("SELECT fingerprint, any"). - WithArgs("test_metric", "__name__", "test_metric"). + WithArgs("test_metric"). WillReturnRows(cmock.NewRows(fingerprintCols, fingerprintData)) telemetryStore.Mock(). ExpectQuery("SELECT metric_name, fingerprint, unix_milli"). - WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart, queryEnd). + WithArgs("test_metric", "test_metric", queryStart, queryEnd). WillReturnRows(cmock.NewRows(samplesCols, samplesData)) promProvider := prometheustest.New( context.Background(),