Compare commits

...

3 Commits

Author SHA1 Message Date
Tushar Vats
52e3b9c7a0 fix: removed extra options 2026-06-15 15:36:01 +05:30
Tushar Vats
44c363ab95 fix: consistent naming 2026-06-15 12:28:08 +05:30
Tushar Vats
c8c050053c fix: draft 2026-06-11 01:21:25 +05:30
21 changed files with 1769 additions and 5 deletions

View File

@@ -5276,6 +5276,22 @@ components:
nullable: true
type: array
type: object
Querybuildertypesv5EstimateEntry:
properties:
database:
type: string
marks:
format: int64
type: integer
parts:
format: int64
type: integer
rows:
format: int64
type: integer
table:
type: string
type: object
Querybuildertypesv5ExecStats:
description: Execution statistics for the query, including rows scanned, bytes
scanned, and duration.
@@ -5343,6 +5359,25 @@ components:
- anomaly
- fillzero
type: string
Querybuildertypesv5Granules:
properties:
initial:
format: int64
type: integer
reads:
items:
$ref: '#/components/schemas/Querybuildertypesv5MergeTreeRead'
type: array
selected:
format: int64
type: integer
skipScore:
format: double
type: number
skipped:
format: int64
type: integer
type: object
Querybuildertypesv5GroupByKey:
properties:
description:
@@ -5365,6 +5400,31 @@ components:
expression:
type: string
type: object
Querybuildertypesv5IndexStep:
properties:
condition:
type: string
initialGranules:
format: int64
type: integer
initialParts:
format: int64
type: integer
keys:
items:
type: string
type: array
name:
type: string
selectedGranules:
format: int64
type: integer
selectedParts:
format: int64
type: integer
type:
type: string
type: object
Querybuildertypesv5Label:
properties:
key:
@@ -5388,6 +5448,16 @@ components:
expression:
type: string
type: object
Querybuildertypesv5MergeTreeRead:
properties:
steps:
items:
$ref: '#/components/schemas/Querybuildertypesv5IndexStep'
nullable: true
type: array
table:
type: string
type: object
Querybuildertypesv5MetricAggregation:
properties:
comparisonSpaceAggregationParam:
@@ -5432,6 +5502,20 @@ components:
- asc
- desc
type: string
Querybuildertypesv5PreviewStatement:
properties:
db.statement.args:
items: {}
type: array
db.statement.query:
type: string
estimate:
items:
$ref: '#/components/schemas/Querybuildertypesv5EstimateEntry'
type: array
granules:
$ref: '#/components/schemas/Querybuildertypesv5Granules'
type: object
Querybuildertypesv5PromQuery:
properties:
disabled:
@@ -5742,6 +5826,39 @@ components:
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
type: object
Querybuildertypesv5QueryPreview:
properties:
error: {}
magnitudeScore:
nullable: true
type: number
selectivityScore:
nullable: true
type: number
statements:
items:
$ref: '#/components/schemas/Querybuildertypesv5PreviewStatement'
type: array
valid:
type: boolean
warnings:
items:
type: string
type: array
type: object
Querybuildertypesv5QueryRangePreviewResponse:
description: Response from the v5 query range preview (dry-run) endpoint. For
each query in the composite query, returns the underlying ClickHouse statement(s)
it renders to without executing them (one per PromQL metric selector; exactly
one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN
ESTIMATE and granule analysis attached per statement when requested.
properties:
compositeQuery:
additionalProperties:
$ref: '#/components/schemas/Querybuildertypesv5QueryPreview'
nullable: true
type: object
type: object
Querybuildertypesv5QueryRangeRequest:
description: Request body for the v5 query range endpoint. Supports builder
queries (traces, logs, metrics), formulas, joins, trace operators, PromQL,
@@ -21137,6 +21254,78 @@ paths:
summary: Query range
tags:
- querier
/api/v5/query_range/preview:
post:
deprecated: false
description: 'Validate a composite query without executing it. Accepts the same
payload as the query range endpoint. By default (verbose=true) returns, for
each query, the rendered underlying ClickHouse statement(s) with each statement''s
EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving
granules, skip score, and the per-index pruning funnel), plus two top-level
scores: selectivityScore (0-100 granule-skip selectivity; higher is better)
and magnitudeScore (0-100 absolute scan cost; higher is cheaper). Pass ?verbose=false
for the lightweight per-query verdict (valid/error/warnings) with no rendered
SQL and no ClickHouse round trips. Intended for agentic/dry-run consumption:
per-query errors are reported in the response rather than failing the whole
request.'
operationId: QueryRangePreviewV5
parameters:
- in: query
name: verbose
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/Querybuildertypesv5QueryRangePreviewResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Query range preview
tags:
- querier
/api/v5/substitute_vars:
post:
deprecated: false

View File

@@ -101,6 +101,10 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
h.community.QueryRange(rw, req)
}
func (h *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Request) {
h.community.QueryRangePreview(rw, req)
}
func (h *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request) {
h.community.QueryRawStream(rw, req)
}

View File

@@ -12,6 +12,8 @@ import type {
} from 'react-query';
import type {
QueryRangePreviewV5200,
QueryRangePreviewV5Params,
QueryRangeV5200,
Querybuildertypesv5QueryRangeRequestDTO,
RenderErrorResponseDTO,
@@ -104,6 +106,107 @@ export const useQueryRangeV5 = <
> => {
return useMutation(getQueryRangeV5MutationOptions(options));
};
/**
* Validate a composite query without executing it. Accepts the same payload as the query range endpoint. By default (verbose=true) returns, for each query, the rendered underlying ClickHouse statement(s) with each statement's EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving granules, skip score, and the per-index pruning funnel), plus two top-level scores: selectivityScore (0-100 granule-skip selectivity; higher is better) and magnitudeScore (0-100 absolute scan cost; higher is cheaper). Pass ?verbose=false for the lightweight per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse round trips. Intended for agentic/dry-run consumption: per-query errors are reported in the response rather than failing the whole request.
* @summary Query range preview
*/
export const queryRangePreviewV5 = (
querybuildertypesv5QueryRangeRequestDTO?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>,
params?: QueryRangePreviewV5Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<QueryRangePreviewV5200>({
url: `/api/v5/query_range/preview`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: querybuildertypesv5QueryRangeRequestDTO,
params,
signal,
});
};
export const getQueryRangePreviewV5MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
TError,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
TError,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
},
TContext
> => {
const mutationKey = ['queryRangePreviewV5'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
}
> = (props) => {
const { data, params } = props ?? {};
return queryRangePreviewV5(data, params);
};
return { mutationFn, ...mutationOptions };
};
export type QueryRangePreviewV5MutationResult = NonNullable<
Awaited<ReturnType<typeof queryRangePreviewV5>>
>;
export type QueryRangePreviewV5MutationBody =
| BodyType<Querybuildertypesv5QueryRangeRequestDTO>
| undefined;
export type QueryRangePreviewV5MutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Query range preview
*/
export const useQueryRangePreviewV5 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
TError,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
TError,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
},
TContext
> => {
return useMutation(getQueryRangePreviewV5MutationOptions(options));
};
/**
* Replace variables in a query
* @summary Replace variables

View File

@@ -6851,6 +6851,32 @@ export interface Querybuildertypesv5ColumnDescriptorDTO {
unit?: string;
}
export interface Querybuildertypesv5EstimateEntryDTO {
/**
* @type string
*/
database?: string;
/**
* @type integer
* @format int64
*/
marks?: number;
/**
* @type integer
* @format int64
*/
parts?: number;
/**
* @type integer
* @format int64
*/
rows?: number;
/**
* @type string
*/
table?: string;
}
export type Querybuildertypesv5ExecStatsDTOStepIntervals = {
[key: string]: number;
};
@@ -6891,6 +6917,99 @@ export interface Querybuildertypesv5FormatOptionsDTO {
formatTableResultForUI?: boolean;
}
export interface Querybuildertypesv5IndexStepDTO {
/**
* @type string
*/
condition?: string;
/**
* @type integer
* @format int64
*/
initialGranules?: number;
/**
* @type integer
* @format int64
*/
initialParts?: number;
/**
* @type array
*/
keys?: string[];
/**
* @type string
*/
name?: string;
/**
* @type integer
* @format int64
*/
selectedGranules?: number;
/**
* @type integer
* @format int64
*/
selectedParts?: number;
/**
* @type string
*/
type?: string;
}
export interface Querybuildertypesv5MergeTreeReadDTO {
/**
* @type array,null
*/
steps?: Querybuildertypesv5IndexStepDTO[] | null;
/**
* @type string
*/
table?: string;
}
export interface Querybuildertypesv5GranulesDTO {
/**
* @type integer
* @format int64
*/
initial?: number;
/**
* @type array
*/
reads?: Querybuildertypesv5MergeTreeReadDTO[];
/**
* @type integer
* @format int64
*/
selected?: number;
/**
* @type number
* @format double
*/
skipScore?: number;
/**
* @type integer
* @format int64
*/
skipped?: number;
}
export interface Querybuildertypesv5PreviewStatementDTO {
/**
* @type array
*/
'db.statement.args'?: unknown[];
/**
* @type string
*/
'db.statement.query'?: string;
/**
* @type array
*/
estimate?: Querybuildertypesv5EstimateEntryDTO[];
granules?: Querybuildertypesv5GranulesDTO;
}
export interface Querybuildertypesv5TimeSeriesDataDTO {
/**
* @type array,null
@@ -6972,6 +7091,49 @@ export type Querybuildertypesv5QueryDataDTO =
results?: unknown[] | null;
});
export interface Querybuildertypesv5QueryPreviewDTO {
error?: unknown;
/**
* @type number,null
*/
magnitudeScore?: number | null;
/**
* @type number,null
*/
selectivityScore?: number | null;
/**
* @type array
*/
statements?: Querybuildertypesv5PreviewStatementDTO[];
/**
* @type boolean
*/
valid?: boolean;
/**
* @type array
*/
warnings?: string[];
}
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf =
{ [key: string]: Querybuildertypesv5QueryPreviewDTO };
/**
* @nullable
*/
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery =
Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf | null;
/**
* Response from the v5 query range preview (dry-run) endpoint. For each query in the composite query, returns the underlying ClickHouse statement(s) it renders to without executing them (one per PromQL metric selector; exactly one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN ESTIMATE and granule analysis attached per statement when requested.
*/
export interface Querybuildertypesv5QueryRangePreviewResponseDTO {
/**
* @type object,null
*/
compositeQuery?: Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery;
}
export enum Querybuildertypesv5VariableTypeDTO {
query = 'query',
dynamic = 'dynamic',
@@ -10527,6 +10689,22 @@ export type QueryRangeV5200 = {
status: string;
};
export type QueryRangePreviewV5Params = {
/**
* @type string
* @description undefined
*/
verbose?: string;
};
export type QueryRangePreviewV5200 = {
data: Querybuildertypesv5QueryRangePreviewResponseDTO;
/**
* @type string
*/
status: string;
};
export type ReplaceVariables200 = {
data: Querybuildertypesv5QueryRangeRequestDTO;
/**

2
go.mod
View File

@@ -180,7 +180,7 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/ClickHouse/ch-go v0.71.0 // indirect
github.com/ClickHouse/ch-go v0.71.0
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/Yiling-J/theine-go v0.6.2 // indirect
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b

View File

@@ -451,6 +451,23 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v5/query_range/preview", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.QueryRangePreview), handler.OpenAPIDef{
ID: "QueryRangePreviewV5",
Tags: []string{"querier"},
Summary: "Query range preview",
Description: "Validate a composite query without executing it. Accepts the same payload as the query range endpoint. By default (verbose=true) returns, for each query, the rendered underlying ClickHouse statement(s) with each statement's EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving granules, skip score, and the per-index pruning funnel), plus two top-level scores: selectivityScore (0-100 granule-skip selectivity; higher is better) and magnitudeScore (0-100 absolute scan cost; higher is cheaper). Pass ?verbose=false for the lightweight per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse round trips. Intended for agentic/dry-run consumption: per-query errors are reported in the response rather than failing the whole request.",
Request: new(qbtypes.QueryRangeRequest),
RequestQuery: new(qbtypes.QueryRangePreviewParams),
RequestContentType: "application/json",
Response: new(qbtypes.QueryRangePreviewResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v5/substitute_vars", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.ReplaceVariables), handler.OpenAPIDef{
ID: "ReplaceVariables",
Tags: []string{"querier"},

View File

@@ -0,0 +1,94 @@
package clickhouseprometheus
import (
"context"
"sync"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/prometheus/prometheus/prompb"
"github.com/prometheus/prometheus/storage"
)
// statementRecorder collects the ClickHouse statements a PromQL evaluation would
// run. It is safe for concurrent use because the Prometheus engine may evaluate
// (and therefore Select) multiple selectors concurrently.
type statementRecorder struct {
mu sync.Mutex
statements []prometheus.CapturedStatement
}
func (r *statementRecorder) record(query string, args []any) {
r.mu.Lock()
defer r.mu.Unlock()
r.statements = append(r.statements, prometheus.CapturedStatement{Query: query, Args: args})
}
func (r *statementRecorder) Statements() []prometheus.CapturedStatement {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]prometheus.CapturedStatement, len(r.statements))
copy(out, r.statements)
return out
}
// captureClient is a remote.ReadClient that builds the same ClickHouse SQL as
// the real client but records it instead of executing, returning an empty
// result so the engine completes without touching ClickHouse. It records the
// self-contained samples query per selector (which embeds the series-selection
// subquery), so the recorded statement reflects the actual data read.
type captureClient struct {
*client
recorder *statementRecorder
}
func (c *captureClient) Read(ctx context.Context, query *prompb.Query, _ bool) (storage.SeriesSet, error) {
// Raw-SQL passthrough ({job="rawsql", query="..."}): record the raw query.
if len(query.Matchers) == 2 {
var hasJob bool
var queryString string
for _, m := range query.Matchers {
if m.Type == prompb.LabelMatcher_EQ && m.Name == "job" && m.Value == "rawsql" {
hasJob = true
}
if m.Type == prompb.LabelMatcher_EQ && m.Name == "query" {
queryString = m.Value
}
}
if hasJob && queryString != "" {
c.recorder.record(queryString, nil)
return storage.EmptySeriesSet(), nil
}
}
var metricName string
for _, matcher := range query.Matchers {
if matcher.Name == "__name__" {
metricName = matcher.Value
}
}
// Build the series-selection subquery and the self-contained samples query
// exactly as the executing path would, but only record them.
subQuery, args, err := c.client.queryToClickhouseQuery(ctx, query, metricName, true)
if err != nil {
return nil, err
}
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricName, subQuery, args)
c.recorder.record(samplesQuery, samplesArgs)
return storage.EmptySeriesSet(), nil
}
// captureQueryable adapts the capturing read client to storage.Queryable,
// mirroring how the real provider wraps its querier.
type captureQueryable struct {
inner storage.SampleAndChunkQueryable
}
func (c captureQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
querier, err := c.inner.Querier(mint, maxt)
if err != nil {
return nil, err
}
return storage.NewMergeQuerier(nil, []storage.Querier{querier}, storage.ChainedSeriesMerge), nil
}

View File

@@ -204,8 +204,11 @@ func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, qu
return fingerprints, nil
}
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) {
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
// buildSamplesQuery renders the samples SQL (and its args) that fetches the
// data points for the series selected by subQuery. It embeds the series
// selection as a subquery, so the returned statement is self-contained — the
// dry-run/preview path renders it without executing.
func buildSamplesQuery(start int64, end int64, metricName string, subQuery string, args []any) (string, []any) {
argCount := len(args)
query := fmt.Sprintf(`
@@ -217,6 +220,13 @@ func (client *client) querySamples(ctx context.Context, start int64, end int64,
allArgs := append([]any{metricName}, args...)
allArgs = append(allArgs, start, end)
return query, allArgs
}
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) {
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
query, allArgs := buildSamplesQuery(start, end, metricName, subQuery, args)
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, allArgs...)
if err != nil {

View File

@@ -5,8 +5,8 @@ import (
"sort"
"testing"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/stretchr/testify/require"
"github.com/DATA-DOG/go-sqlmock"

View File

@@ -64,3 +64,17 @@ func (provider *provider) Querier(mint, maxt int64) (storage.Querier, error) {
return storage.NewMergeQuerier(nil, []storage.Querier{querier}, storage.ChainedSeriesMerge), nil
}
// CapturingStorage implements prometheus.StatementCapturer: it returns a Storage
// that records the ClickHouse SQL each selector would run (without executing
// it) and a recorder to read the captured statements back. A fresh recorder is
// created per call so concurrent dry-runs don't share state.
func (provider *provider) CapturingStorage() (storage.Queryable, prometheus.StatementRecorder) {
recorder := &statementRecorder{}
capture := &captureClient{
client: &client{settings: provider.settings, telemetryStore: provider.telemetryStore},
recorder: recorder,
}
queryable := remote.NewSampleAndChunkQueryableClient(capture, labels.EmptyLabels(), []*labels.Matcher{}, false, stCallback)
return captureQueryable{inner: queryable}, recorder
}

View File

@@ -15,3 +15,25 @@ type Prometheus interface {
Storage() storage.Queryable
Parser() Parser
}
// CapturedStatement is one underlying datastore statement that a PromQL query would
// run, captured without executing it.
type CapturedStatement struct {
Query string
Args []any
}
// StatementRecorder collects the Statements captured while a PromQL query is
// evaluated against a capturing Storage (see StatementCapturer).
type StatementRecorder interface {
Statements() []CapturedStatement
}
// StatementCapturer is an optional capability of a Prometheus provider: it
// returns a Storage that records the datastore statement(s) each Select would
// run — without executing them — together with a recorder to read them back.
// The query dry-run path discovers it via a type assertion, so providers that
// do not implement it simply expose no underlying SQL.
type StatementCapturer interface {
CapturingStorage() (storage.Queryable, StatementRecorder)
}

View File

@@ -72,6 +72,60 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, queryRangeResponse)
}
// QueryRangePreview is the dry-run counterpart of QueryRange. It accepts the
// same payload, validates and renders the underlying SQL/PromQL for each query
// without executing it, and returns the per-query statements. ?verbose defaults
// to true: each rendered statement carries its ClickHouse EXPLAIN ESTIMATE and
// granule index analysis (with the top-level scores). ?verbose=false returns the
// lightweight verdict-only response with no rendered SQL.
func (handler *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "querier",
instrumentationtypes.CodeFunctionName: "QueryRangePreview",
})
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
var queryRangeRequest qbtypes.QueryRangeRequest
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
render.Error(rw, err)
return
}
// NB: validation is intentionally NOT done here. QueryRangePreview checks
// request-level invariants (aborting on failure) and validates each query's
// spec individually, reporting per-query structural errors in the response
// instead of failing fast on the first one — the point of the dry-run.
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
// verbose defaults to true (full preview); ?verbose=false returns the
// lightweight verdict-only response.
verbose, err := ParseVerbose(req.URL.Query().Get("verbose"))
if err != nil {
render.Error(rw, err)
return
}
preview, err := handler.querier.QueryRangePreview(ctx, orgID, &queryRangeRequest, qbtypes.QueryRangePreviewOptions{Verbose: verbose})
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, preview)
}
func (handler *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()

View File

@@ -194,6 +194,12 @@ func (q *builderQuery[T]) isWindowList() bool {
return true
}
// Statement renders the SQL statement for the builder query without executing
// it. It is used by the dry-run/preview path.
func (q *builderQuery[T]) Statement(ctx context.Context) (*qbtypes.Statement, error) {
return q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
}
func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error) {
// can we do window based pagination?

View File

@@ -99,6 +99,16 @@ func (q *chSQLQuery) renderVars(query string, vars map[string]qbtypes.VariableIt
return newQuery.String(), nil
}
// Statement renders the SQL statement for the ClickHouse SQL query without
// executing it. It is used by the dry-run/preview path.
func (q *chSQLQuery) Statement(_ context.Context) (*qbtypes.Statement, error) {
rendered, err := q.renderVars(q.query.Query, q.vars, q.fromMS, q.toMS)
if err != nil {
return nil, err
}
return &qbtypes.Statement{Query: rendered, Args: q.args}, nil
}
func (q *chSQLQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.QueryDuration: instrumentationtypes.DurationBucket(q.fromMS, q.toMS),

View File

@@ -12,6 +12,11 @@ import (
type Querier interface {
QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error)
QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream)
// QueryRangePreview validates and renders the queries in req without
// executing them. opts controls dry-run behavior such as which
// EXPLAIN variant to attach to the response; the zero value performs
// a validation-only preview with no EXPLAIN.
QueryRangePreview(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, opts qbtypes.QueryRangePreviewOptions) (*qbtypes.QueryRangePreviewResponse, error)
}
// BucketCache is the interface for bucket-based caching.
@@ -24,6 +29,10 @@ type BucketCache interface {
type Handler interface {
QueryRange(rw http.ResponseWriter, req *http.Request)
// QueryRangePreview is the dry-run endpoint: it validates and renders the
// queries without executing them, optionally attaching each statement's
// ClickHouse EXPLAIN ESTIMATE (?estimate=) and granule analysis (?granules=).
QueryRangePreview(rw http.ResponseWriter, req *http.Request)
QueryRawStream(rw http.ResponseWriter, req *http.Request)
ReplaceVariables(rw http.ResponseWriter, req *http.Request)
}

734
pkg/querier/preview.go Normal file
View File

@@ -0,0 +1,734 @@
package querier
import (
"context"
"encoding/json"
"fmt"
"math"
"reflect"
"slices"
"strings"
"sync"
chproto "github.com/ClickHouse/ch-go/proto"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// statementProvider is implemented by query types that can render the
// underlying SQL/PromQL statement without executing it.
type statementProvider interface {
Statement(ctx context.Context) (*qbtypes.Statement, error)
}
// missingMetricNames returns the distinct metric names referenced by a metric
// builder query, in order of first appearance. It is used to name the metric(s)
// in the warning attached to a fully-missing-metric query. Returns nil for any
// non-metric query.
func missingMetricNames(env qbtypes.QueryEnvelope) []string {
spec, ok := env.Spec.(qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation])
if !ok {
return nil
}
names := make([]string, 0, len(spec.Aggregations))
for _, agg := range spec.Aggregations {
if agg.MetricName != "" && !slices.Contains(names, agg.MetricName) {
names = append(names, agg.MetricName)
}
}
return names
}
// ParseVerbose parses the ?verbose= query parameter. It defaults to TRUE: the
// full preview — rendered ClickHouse statement(s) with each statement's EXPLAIN
// ESTIMATE and granule index analysis, plus the top-level scores — is returned
// unless explicitly disabled with verbose=false, which gives the lightweight
// verdict-only preview (valid/error/warnings per query, no ClickHouse round trips).
func ParseVerbose(value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "true", "1":
return true, nil
case "false", "0":
return false, nil
}
return false, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid verbose value %q (allowed: true, false)", value)
}
// QueryRangePreview validates each query in the composite query without
// executing it. With opts.Verbose=false it returns a lightweight per-query
// verdict (valid/error/warnings). With opts.Verbose=true it also renders the
// underlying ClickHouse statement(s) each query would run and attaches, per
// statement, the EXPLAIN ESTIMATE and granule index analysis, deriving the
// top-level SelectivityScore and MagnitudeScore.
func (q *querier) QueryRangePreview(
ctx context.Context,
_ valuer.UUID,
req *qbtypes.QueryRangeRequest,
opts qbtypes.QueryRangePreviewOptions,
) (*qbtypes.QueryRangePreviewResponse, error) {
// The preview must transform the payload exactly as QueryRange does so the
// rendered SQL matches what the same payload will actually execute. Coerce
// the window to epoch milliseconds up front, just like QueryRange.
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
tmplVars := req.Variables
if tmplVars == nil {
tmplVars = make(map[string]qbtypes.VariableItem)
}
// Validate request-level invariants (time range, request type, unique
// names, …) up front — these are request-wide, so there is nothing per
// query to preview if they fail. Per-query spec validation is deliberately
// NOT done here: it runs per query below so each query's structural error is
// reported in its own QueryPreview instead of aborting the whole
// preview on the first one. validationOpts carries the request-type-specific
// options into that per-query validation.
validationOpts, err := req.ValidateRequestScope()
if err != nil {
return nil, err
}
// Queries that only feed a trace operator (e.g. A and B in C := A => B) are
// not executed standalone by QueryRange. The dry-run, by contrast, still
// previews each of them on its own so the caller can see *which* sub-query of
// a trace operator is the bad one instead of getting a single opaque failure
// on C. The map identifies those dependencies; their rendering is specialized
// below (see requestType).
dependencyQueries, err := q.constructTraceOperatorDependencyMap(req.CompositeQuery.Queries)
if err != nil {
return nil, err
}
results := make(map[string]qbtypes.QueryPreview, len(req.CompositeQuery.Queries))
// Phase 1: normalize every query's spec (step interval + metric metadata)
// and capture the per-query warnings/errors. This runs for ALL queries —
// including trace-operator dependencies — before any statement is rendered,
// because a trace-operator query reads its siblings' specs at render time
// and they must already be normalized. adjustStepInterval and
// resolveMetricMetadata both patch the spec in place, so feed each a
// single-element slice and write the patched envelope back into the
// composite query. Doing it per-query (rather than once over all queries
// like QueryRange) lets us attribute each warning/error to the query that
// produced it, which is the whole point of a per-query preview report; the
// extra metadata lookups are acceptable on this low-volume dry-run path.
prepared := make(map[string]qbtypes.QueryPreview, len(req.CompositeQuery.Queries))
missingMetricQuerySet := make(map[string]bool)
for idx := range req.CompositeQuery.Queries {
name := req.CompositeQuery.Queries[idx].GetQueryName()
ps := qbtypes.QueryPreview{}
// Validate this query's spec on its own and attribute any structural
// error to it, instead of aborting the whole preview on the first bad
// query (the request-level invariants were already checked above). An
// invalid spec gets no step/metadata normalization or rendering.
if vErr := req.CompositeQuery.Queries[idx].Validate(validationOpts...); vErr != nil {
ps.Error = vErr
prepared[name] = ps
continue
}
env := []qbtypes.QueryEnvelope{req.CompositeQuery.Queries[idx]}
ps.Warnings = q.adjustStepInterval(env, req.Start, req.End)
missingMetricQueries, dormantMetricsWarningMsg, mErr := q.resolveMetricMetadata(ctx, env, req.Start, req.End)
if mErr != nil {
// Don't abort the whole preview: report this query's error and keep
// going so the agent sees every problem in one round trip.
ps.Error = mErr
} else {
if dormantMetricsWarningMsg != "" {
ps.Warnings = append(ps.Warnings, dormantMetricsWarningMsg)
}
if len(missingMetricQueries) > 0 {
missingMetricQuerySet[name] = true
// A fully-missing-metric query renders no SQL and returns an empty
// result, so flag it explicitly. resolveMetricMetadata only emits a
// (dormant) warning for external metrics it has seen before; when it
// stays silent — e.g. internal signoz.* metrics — the empty result
// would otherwise be unexplained, so attach a clear note naming the
// metric(s) the agent referenced.
if dormantMetricsWarningMsg == "" {
if metricNames := missingMetricNames(env[0]); len(metricNames) > 0 {
ps.Warnings = append(ps.Warnings, fmt.Sprintf(
"query %q references metric(s) %s with no data available; it will return an empty result",
name, strings.Join(metricNames, ", ")))
}
}
}
}
req.CompositeQuery.Queries[idx] = env[0]
prepared[name] = ps
}
// Phase 2: render the statement for each query that actually executes, and
// collect the ClickHouse-bound work (granules/estimate analyses) to run concurrently.
var previewTasks []previewTask
for _, query := range req.CompositeQuery.Queries {
name := query.GetQueryName()
// A trace-operator dependency is previewed on its own (so the caller sees
// which sub-query is bad), but it must be rendered the way the operator
// actually consumes it: only the sub-query's span *selection* (its filter)
// feeds the operator's CTE — aggregation/group-by/order run later on the
// combined result, not per sub-query (see the trace operator CTE builder).
// So render a dependency as a RequestTypeRaw span selection, mirroring that
// CTE, rather than with the request's own type (which would validate
// aggregations the operator never applies to it). For every other query
// (including the trace operator itself) the request's type is used.
requestType := req.RequestType
if query.GetType() != qbtypes.QueryTypeTraceOperator && dependencyQueries[name] {
requestType = qbtypes.RequestTypeRaw
}
ps := prepared[name]
// Surface a phase-1 error (e.g. a not-found metric) without rendering.
if ps.Error != nil {
results[name] = ps
continue
}
// Every aggregation resolved to a missing metric: QueryRange returns an
// empty result for this query and renders no SQL. Mirror that.
if missingMetricQuerySet[name] {
results[name] = ps
continue
}
var provider qbtypes.Query
switch query.Type {
case qbtypes.QueryTypePromQL:
promQuery, ok := query.Spec.(qbtypes.PromQuery)
if !ok {
ps.Error = errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query spec %T", query.Spec)
results[name] = ps
continue
}
provider = newPromqlQuery(q.logger, q.promEngine, promQuery, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType, tmplVars)
case qbtypes.QueryTypeClickHouseSQL:
chQuery, ok := query.Spec.(qbtypes.ClickHouseQuery)
if !ok {
ps.Error = errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid clickhouse query spec %T", query.Spec)
results[name] = ps
continue
}
provider = newchSQLQuery(q.logger, q.telemetryStore, chQuery, nil, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType, tmplVars)
case qbtypes.QueryTypeTraceOperator:
traceOpQuery, ok := query.Spec.(qbtypes.QueryBuilderTraceOperator)
if !ok {
ps.Error = errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid trace operator query spec %T", query.Spec)
results[name] = ps
continue
}
provider = &traceOperatorQuery{
telemetryStore: q.telemetryStore,
stmtBuilder: q.traceOperatorStmtBuilder,
spec: traceOpQuery,
compositeQuery: &req.CompositeQuery,
fromMS: uint64(req.Start),
toMS: uint64(req.End),
kind: requestType,
}
case qbtypes.QueryTypeBuilder:
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
provider = newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, spec, timeRange, requestType, tmplVars)
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
stmtBuilder := q.logStmtBuilder
if spec.Source == telemetrytypes.SourceAudit {
stmtBuilder = q.auditStmtBuilder
}
provider = newBuilderQuery(q.logger, q.telemetryStore, stmtBuilder, spec, timeRange, requestType, tmplVars)
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
if spec.Source == telemetrytypes.SourceMeter {
provider = newBuilderQuery(q.logger, q.telemetryStore, q.meterStmtBuilder, spec, timeRange, requestType, tmplVars)
} else {
provider = newBuilderQuery(q.logger, q.telemetryStore, q.metricStmtBuilder, spec, timeRange, requestType, tmplVars)
}
default:
ps.Error = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported builder spec type %T", query.Spec)
results[name] = ps
continue
}
default:
ps.Error = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported query type %q", query.Type)
results[name] = ps
continue
}
stmtProvider, ok := provider.(statementProvider)
if !ok {
ps.Error = errors.NewInternalf(errors.CodeInternal, "query does not support preview")
results[name] = ps
continue
}
// Build the statement even in validate-only mode: a successful build is
// the strongest validation we can do (it parses the filter/group-by and
// resolves fields against the schema), and a build error is exactly the
// per-query verdict a validation caller wants.
stmt, sErr := stmtProvider.Statement(ctx)
if sErr != nil {
ps.Error = sErr
results[name] = ps
continue
}
ps.Warnings = append(ps.Warnings, stmt.Warnings...)
// clickhouse_sql is user-authored raw SQL; rendering only substitutes
// variables, so by itself it doesn't prove the SQL is valid. Verify it
// parses and binds (tables/columns/types resolve) via EXPLAIN PLAN —
// without executing. Builder/PromQL/trace-operator SQL is engine-generated
// and well-formed by construction, so this is scoped to clickhouse_sql.
if query.Type == qbtypes.QueryTypeClickHouseSQL {
if invalidErr, infraErr := q.explainBindCheck(ctx, stmt.Query, stmt.Args); invalidErr != nil {
ps.Error = invalidErr
results[name] = ps
continue
} else if infraErr != nil {
ps.Warnings = append(ps.Warnings, "could not validate ClickHouse SQL: "+infraErr.Error())
}
}
// The query is fully validated by this point (statement built, plus the
// clickhouse_sql bind check). In verbose mode, render the underlying
// statement(s) into the response and attach the EXPLAIN analyses below;
// otherwise return just the verdict.
if !opts.Verbose {
results[name] = ps
continue
}
// Every query exposes its underlying ClickHouse statement(s) uniformly in
// Statements. Builder/ClickHouse/trace-operator render exactly one; PromQL
// is not SQL — the Prometheus engine issues one statement per metric
// selector, captured (without executing) via PreviewStatements.
if query.Type == qbtypes.QueryTypePromQL {
if pq, ok := provider.(*promqlQuery); ok {
sqlStmts, pErr := pq.PreviewStatements(ctx)
if pErr != nil {
ps.Warnings = append(ps.Warnings, "could not render underlying ClickHouse SQL: "+pErr.Error())
} else {
for _, s := range sqlStmts {
ps.Statements = append(ps.Statements, qbtypes.PreviewStatement{Query: s.Query, Args: s.Args})
}
}
}
} else {
ps.Statements = []qbtypes.PreviewStatement{{Query: stmt.Query, Args: stmt.Args}}
}
results[name] = ps
// The granules and estimate analyses both hit ClickHouse. Queue one task
// per statement; runPreviewTasks executes them concurrently across queries
// after rendering, rather than serializing one query's round trips behind
// the next.
for j := range ps.Statements {
previewTasks = append(previewTasks, previewTask{name: name, stmtIdx: j, query: ps.Statements[j].Query, args: ps.Statements[j].Args})
}
}
q.runPreviewTasks(ctx, previewTasks, results)
// Derive the two headline per-query scores from the rendered statements, each
// from the worst (heaviest) statement that dominates cost:
// - SelectivityScore: the minimum granule SkipScore (selectivity — how good
// the index pruning ratio is), when the granules analysis ran.
// - MagnitudeScore: the minimum per-statement magnitude score (absolute scan
// size — a query can prune 99% and still scan billions), derived from each
// statement's estimated rows, when the estimate analysis ran.
// They are intentionally kept as separate axes, not fused into one number.
for name, ps := range results {
var minSelectivity, minMagnitude *float64
for i := range ps.Statements {
if g := ps.Statements[i].Granules; g != nil && (minSelectivity == nil || g.SkipScore < *minSelectivity) {
s := g.SkipScore
minSelectivity = &s
}
if est := ps.Statements[i].Estimate; len(est) > 0 {
var rows int64
for j := range est {
rows += est[j].Rows
}
if m := magnitudeScoreFromRows(rows); minMagnitude == nil || m < *minMagnitude {
minMagnitude = &m
}
}
}
if minSelectivity != nil {
ps.SelectivityScore = minSelectivity
}
if minMagnitude != nil {
ps.MagnitudeScore = minMagnitude
}
results[name] = ps
}
return &qbtypes.QueryRangePreviewResponse{
CompositeQuery: results,
}, nil
}
// previewTask is one rendered ClickHouse statement queued for ClickHouse-bound
// preview work (the granules and/or estimate analysis). stmtIdx is the index into
// the query's Statements list that this task's results merge back into.
type previewTask struct {
name string
stmtIdx int
query string
args []any
}
// runPreviewTasks computes the granules and estimate analysis for each task
// concurrently — every query's ClickHouse round trips are in flight at once
// instead of serialized — and merges the outcomes back into previews. Tasks are
// only queued in verbose mode, so both analyses run for every task. A composite
// query holds only a handful of queries, so a goroutine per task is fine without
// an explicit concurrency bound. Each goroutine writes to its own slot; the
// merge into the previews map happens after the wait, single-threaded, so there
// are no map races.
func (q *querier) runPreviewTasks(ctx context.Context, tasks []previewTask, previews map[string]qbtypes.QueryPreview) {
if len(tasks) == 0 {
return
}
type outcome struct {
granules *qbtypes.Granules
estimate []qbtypes.EstimateEntry
warnings []string
}
outcomes := make([]outcome, len(tasks))
var wg sync.WaitGroup
for i := range tasks {
wg.Add(1)
go func(i int) {
defer wg.Done()
t := tasks[i]
var out outcome
if granules, scErr := q.computeGranuleStats(ctx, t.query, t.args); scErr != nil {
// Surface the failure instead of silently dropping the score.
out.warnings = append(out.warnings, "could not compute query score: "+scErr.Error())
} else if granules != nil {
out.granules = granules
}
if estimate, eErr := q.runExplainEstimate(ctx, t.query, t.args); eErr != nil {
// Surface the failure instead of silently dropping the output.
out.warnings = append(out.warnings, "could not run EXPLAIN ESTIMATE: "+eErr.Error())
} else {
out.estimate = estimate
}
outcomes[i] = out
}(i)
}
wg.Wait()
for i := range tasks {
ps := previews[tasks[i].name]
if idx := tasks[i].stmtIdx; idx >= 0 && idx < len(ps.Statements) {
if outcomes[i].granules != nil {
ps.Statements[idx].Granules = outcomes[i].granules
}
if len(outcomes[i].estimate) > 0 {
ps.Statements[idx].Estimate = outcomes[i].estimate
}
}
ps.Warnings = append(ps.Warnings, outcomes[i].warnings...)
previews[tasks[i].name] = ps
}
}
// runExplainEstimate runs `EXPLAIN ESTIMATE <stmt>` and parses its per-table
// estimate into structs. ESTIMATE returns one row per table the query reads,
// with five columns (database, table, parts, rows, marks); the numeric columns
// come back as unsigned integers from the driver. Columns are matched by name
// (not position) so the parse is robust to column reordering.
func (q *querier) runExplainEstimate(ctx context.Context, stmt string, args []any) ([]qbtypes.EstimateEntry, error) {
rows, err := q.telemetryStore.ClickhouseDB().Query(ctx, "EXPLAIN ESTIMATE "+stmt, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to run EXPLAIN ESTIMATE")
}
defer rows.Close()
colTypes := rows.ColumnTypes()
var entries []qbtypes.EstimateEntry
for rows.Next() {
dest := make([]any, len(colTypes))
for i, ct := range colTypes {
dest[i] = reflect.New(ct.ScanType()).Interface()
}
if err := rows.Scan(dest...); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan EXPLAIN ESTIMATE row")
}
var entry qbtypes.EstimateEntry
for i, ct := range colTypes {
val := reflect.ValueOf(dest[i]).Elem().Interface()
switch strings.ToLower(ct.Name()) {
case "database":
entry.Database = fmt.Sprintf("%v", val)
case "table":
entry.Table = fmt.Sprintf("%v", val)
case "parts":
entry.Parts = toInt64(val)
case "rows":
entry.Rows = toInt64(val)
case "marks":
entry.Marks = toInt64(val)
}
}
entries = append(entries, entry)
}
if err := rows.Err(); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "EXPLAIN ESTIMATE row iteration failed")
}
return entries, nil
}
// toInt64 coerces a driver-scanned numeric value (ESTIMATE's parts/rows/marks
// arrive as unsigned integers) to int64. A non-numeric value yields 0.
func toInt64(v any) int64 {
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return rv.Int()
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return int64(rv.Uint())
case reflect.Float32, reflect.Float64:
return int64(rv.Float())
default:
return 0
}
}
// userFacingClickHouseErrorCodes mirrors PR #10679's userFacingCHCodes: the
// ClickHouse error codes that indicate a problem with the query itself (bad SQL,
// unknown table/column, …) rather than a server-side/infra failure — i.e. the
// ones that should map to invalid input (400) instead of internal (500).
//
// TODO(#10679): once that PR lands, delete this and have explainBindCheck call
// the shared querier.mapClickHouseError so there's a single source of truth.
var userFacingClickHouseErrorCodes = map[chproto.Error]bool{
chproto.ErrSyntaxError: true,
chproto.ErrUnknownTable: true,
chproto.ErrUnknownDatabase: true,
chproto.ErrUnknownIdentifier: true,
chproto.ErrUnknownFunction: true,
chproto.ErrUnknownAggregateFunction: true,
chproto.ErrUnknownType: true,
chproto.ErrUnknownStorage: true,
chproto.ErrUnknownElementInAst: true,
chproto.ErrUnknownTypeOfQuery: true,
chproto.ErrIllegalTypeOfArgument: true,
chproto.ErrIllegalColumn: true,
chproto.ErrNumberOfArgumentsDoesntMatch: true,
chproto.ErrTooManyArgumentsForFunction: true,
chproto.ErrTooLessArgumentsForFunction: true,
}
// explainBindCheck validates that a rendered ClickHouse statement parses and
// binds (its tables, columns, and types resolve) by running EXPLAIN PLAN
// against it without executing it. It distinguishes two failure modes:
//
// - invalidErr (non-nil): ClickHouse rejected the statement with a user-facing
// error code — it's genuinely invalid input (syntax, unknown table/column,
// type mismatch). The caller marks the query invalid.
// - infraErr (non-nil): the check couldn't run, or ClickHouse failed with a
// non-user-facing code (e.g. unreachable, timeout, server-side). The caller
// warns rather than falsely marking the query invalid, since validity is
// unknown.
//
// Both nil means the statement is valid.
func (q *querier) explainBindCheck(ctx context.Context, stmt string, args []any) (invalidErr error, infraErr error) {
rows, err := q.telemetryStore.ClickhouseDB().Query(ctx, "EXPLAIN PLAN "+stmt, args...)
if err != nil {
var ex *clickhouse.Exception
if errors.As(err, &ex) && userFacingClickHouseErrorCodes[chproto.Error(ex.Code)] {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid ClickHouse SQL: %s", ex.Message), nil
}
return nil, err
}
rows.Close()
return nil, nil
}
// explainPlanNode is the subset of a ClickHouse `EXPLAIN json = 1, indexes = 1`
// plan node the granule analysis needs: the node type, the table it reads (in
// Description, for ReadFromMergeTree nodes), its per-index funnel, and its
// children.
type explainPlanNode struct {
NodeType string `json:"Node Type"`
Description string `json:"Description"`
Indexes []explainPlanIndex `json:"Indexes"`
Plans []explainPlanNode `json:"Plans"`
}
// explainPlanIndex is one index step under a ReadFromMergeTree node. The index
// steps run in sequence, so the first step's Initial Granules is the candidate
// total and the last step's Selected Granules is what survives all pruning. The
// counts are pointers so a step that omits them is distinguishable from a zero.
type explainPlanIndex struct {
Type string `json:"Type"`
Name string `json:"Name"`
Keys []string `json:"Keys"`
Condition string `json:"Condition"`
InitialParts *int64 `json:"Initial Parts"`
SelectedParts *int64 `json:"Selected Parts"`
InitialGranules *int64 `json:"Initial Granules"`
SelectedGranules *int64 `json:"Selected Granules"`
}
// magnitudeReferenceRows is the estimated row count treated as "fully expensive"
// (magnitude score 0) by magnitudeScoreFromRows. It's a heuristic reference — the
// point past which a scan is considered maximally costly — and is deliberately a
// single tunable constant rather than per-table, so the score is comparable
// across queries.
const magnitudeReferenceRows = 1e9
// magnitudeScoreFromRows maps the absolute number of rows a statement is estimated
// to scan (from EXPLAIN ESTIMATE) to a 0-100 cost score on a log scale: 1 row →
// 100, magnitudeReferenceRows (or more) → 0; higher means less data scanned =
// cheaper. This is the absolute-cost counterpart to the granule skip score's
// pruning ratio — the two are orthogonal, so they're reported as separate axes.
func magnitudeScoreFromRows(rows int64) float64 {
if rows <= 1 {
return 100
}
ratio := math.Log10(float64(rows)) / math.Log10(magnitudeReferenceRows)
if ratio < 0 {
ratio = 0
}
if ratio > 1 {
ratio = 1
}
return math.Round((1-ratio)*100*100) / 100 // percentage, 2 decimal places
}
// computeGranuleStats runs `EXPLAIN json = 1, indexes = 1` against the telemetry
// store and returns the granule-skip breakdown: candidate granules, granules
// surviving pruning, granules skipped, and the resulting 0-100 skip score (the
// percentage eliminated by partition, primary-key, and skip-index pruning before
// any data is read — higher = more selective, reads less). Granules are summed
// across every ReadFromMergeTree node so multi-read queries (e.g. a
// resource-filter subquery plus the main read) are scored as a whole, and the
// raw per-read, per-index funnel is preserved in Granules.Reads so a caller can
// see which index pruned and which did nothing. Returns nil — not an error —
// when the plan exposes no MergeTree index analysis, so the caller simply omits
// the score.
func (q *querier) computeGranuleStats(ctx context.Context, stmt string, args []any) (*qbtypes.Granules, error) {
rows, err := q.telemetryStore.ClickhouseDB().Query(ctx, "EXPLAIN json = 1, indexes = 1 "+stmt, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to run EXPLAIN for query score")
}
defer rows.Close()
// json=1 emits the plan as a single JSON document; read every row and join
// so we are robust to the driver splitting it across rows.
var sb strings.Builder
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan EXPLAIN json row")
}
sb.WriteString(line)
sb.WriteByte('\n')
}
if err := rows.Err(); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "EXPLAIN json row iteration failed")
}
var plans []struct {
Plan explainPlanNode `json:"Plan"`
}
if err := json.Unmarshal([]byte(sb.String()), &plans); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse EXPLAIN json")
}
var totalInitial, totalSelected int64
var reads []qbtypes.MergeTreeRead
for i := range plans {
collectMergeTreeReads(&plans[i].Plan, &reads, &totalInitial, &totalSelected)
}
if totalInitial <= 0 {
// No MergeTree index analysis in the plan — nothing to score.
return nil, nil
}
if totalSelected < 0 {
totalSelected = 0
}
skippedGranules := totalInitial - totalSelected
if skippedGranules < 0 {
skippedGranules = 0
}
ratio := float64(skippedGranules) / float64(totalInitial)
score := math.Round(ratio*100*100) / 100 // percentage, 2 decimal places
return &qbtypes.Granules{
Initial: totalInitial,
Selected: totalSelected,
Skipped: skippedGranules,
SkipScore: score,
Reads: reads,
}, nil
}
func derefInt64(p *int64) int64 {
if p == nil {
return 0
}
return *p
}
// collectMergeTreeReads walks the plan tree and, for every ReadFromMergeTree
// node, records its raw per-index funnel (one MergeTreeRead, appended to reads)
// and folds its endpoints into the running totals: the candidate-granule total
// (first index step's Initial Granules) and surviving granules (last index
// step's Selected Granules). The per-step counts are kept verbatim so the caller
// can see the full pruning trace, not just the collapsed initial→selected.
func collectMergeTreeReads(node *explainPlanNode, reads *[]qbtypes.MergeTreeRead, totalInitial, totalSelected *int64) {
if node.NodeType == "ReadFromMergeTree" && len(node.Indexes) > 0 {
steps := make([]qbtypes.IndexStep, 0, len(node.Indexes))
var initial, selected *int64
for i := range node.Indexes {
idx := node.Indexes[i]
if idx.InitialGranules != nil && initial == nil {
initial = idx.InitialGranules
}
if idx.SelectedGranules != nil {
selected = idx.SelectedGranules
}
steps = append(steps, qbtypes.IndexStep{
Type: idx.Type,
Name: idx.Name,
Keys: idx.Keys,
Condition: idx.Condition,
InitialParts: derefInt64(idx.InitialParts),
SelectedParts: derefInt64(idx.SelectedParts),
InitialGranules: derefInt64(idx.InitialGranules),
SelectedGranules: derefInt64(idx.SelectedGranules),
})
}
if initial != nil && selected != nil {
*totalInitial += *initial
*totalSelected += *selected
}
*reads = append(*reads, qbtypes.MergeTreeRead{Table: node.Description, Steps: steps})
}
for i := range node.Plans {
collectMergeTreeReads(&node.Plans[i], reads, totalInitial, totalSelected)
}
}

View File

@@ -220,6 +220,68 @@ func (q *promqlQuery) renderVars(query string, vars map[string]qbv5.VariableItem
return newQuery.String(), nil
}
// Statement renders the PromQL query string after variable substitution. It
// is used by the dry-run/preview path; PromQL queries do not have a
// SQL-style argument list.
func (q *promqlQuery) Statement(_ context.Context) (*qbv5.Statement, error) {
rendered, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
if err != nil {
return nil, err
}
return &qbv5.Statement{Query: rendered}, nil
}
// PreviewStatements returns the underlying ClickHouse statement(s) this PromQL
// query would run, captured without executing them. PromQL is evaluated by the
// Prometheus engine rather than compiled to one SQL statement: the engine calls
// the storage adapter's Select per metric selector, which builds ClickHouse
// SQL. We drive the engine with a capturing Storage that records that SQL and
// returns empty results, so nothing is read from ClickHouse. Returns nil when
// the provider does not support capture (e.g. test doubles).
func (q *promqlQuery) PreviewStatements(ctx context.Context) ([]prometheus.CapturedStatement, error) {
storer, ok := q.promEngine.(prometheus.StatementCapturer)
if !ok {
return nil, nil
}
rendered, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
if err != nil {
return nil, err
}
start := int64(querybuilder.ToNanoSecs(q.tr.From))
end := int64(querybuilder.ToNanoSecs(q.tr.To))
capStorage, recorder := storer.CapturingStorage()
qry, err := q.promEngine.Engine().NewRangeQuery(
ctx,
capStorage,
nil,
rendered,
time.Unix(0, start),
time.Unix(0, end),
q.query.Step.Duration,
)
if err != nil {
if e := tryEnhancePromQLExecError(err); e != nil {
return nil, e
}
return nil, enhancePromQLError(rendered, err)
}
defer qry.Close()
// Evaluate against the capturing storage: this drives a Select per selector
// (recording the SQL) but reads no data, so the result is discarded.
if res := qry.Exec(ctx); res.Err != nil {
if e := tryEnhancePromQLExecError(res.Err); e != nil {
return nil, e
}
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "query execution error: %v", res.Err)
}
return recorder.Statements(), nil
}
func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{

View File

@@ -32,6 +32,12 @@ func (q *traceOperatorQuery) Window() (uint64, uint64) {
return q.fromMS, q.toMS
}
// Statement renders the SQL statement for the trace operator query without
// executing it. It is used by the dry-run/preview path.
func (q *traceOperatorQuery) Statement(ctx context.Context) (*qbtypes.Statement, error) {
return q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.compositeQuery)
}
func (q *traceOperatorQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
stmt, err := q.stmtBuilder.Build(
ctx,

View File

@@ -145,7 +145,7 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (PreparedWhere
"Found %d syntax errors while parsing the search expression.",
len(parserErrorListener.SyntaxErrors),
)
additionals := make([]string, len(parserErrorListener.SyntaxErrors))
additionals := make([]string, 0, len(parserErrorListener.SyntaxErrors))
for _, err := range parserErrorListener.SyntaxErrors {
if err.Error() != "" {
additionals = append(additionals, err.Error())

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
@@ -64,6 +65,195 @@ type QueryRangeResponse struct {
QBEvent *QBEvent `json:"-"`
}
// QueryRangePreviewResponse describes the dry-run output of a query range
// request. CompositeQuery mirrors the request's compositeQuery: each entry is
// the dry-run result for one query, keyed by the same query name the request
// used.
type QueryRangePreviewResponse struct {
CompositeQuery map[string]QueryPreview `json:"compositeQuery"`
}
// QueryRangePreviewOptions carries per-call options for the query range
// preview (dry-run) endpoint. The zero value produces a lightweight,
// verdict-only preview (valid/error/warnings per query, no rendered SQL).
type QueryRangePreviewOptions struct {
// Verbose is the single switch for the full preview, and the HTTP endpoint
// defaults it to TRUE. When true, each rendered statement carries its EXPLAIN
// ESTIMATE (PreviewStatement.Estimate) and granule index analysis
// (PreviewStatement.Granules, including the per-index funnel), and the query
// gets both headline scores (SelectivityScore and MagnitudeScore); the two
// analyses cost one ClickHouse EXPLAIN per statement each. When false (set via
// ?verbose=false) every query is still validated but the response is just the
// per-query verdict, with no rendered SQL and no ClickHouse round trips.
Verbose bool
}
// QueryRangePreviewParams documents the query-string parameters accepted by the
// query range preview (dry-run) endpoint.
type QueryRangePreviewParams struct {
// Verbose defaults to "true": the full preview — the rendered ClickHouse
// statement(s) with each statement's EXPLAIN ESTIMATE and granule index
// analysis, plus the top-level selectivityScore and magnitudeScore. Set
// verbose=false for the lightweight per-query verdict (valid/error/warnings)
// with no rendered SQL and no ClickHouse round trips.
Verbose string `query:"verbose"`
}
// PrepareJSONSchema adds description to the QueryRangePreviewResponse schema.
func (q *QueryRangePreviewResponse) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.WithDescription("Response from the v5 query range preview (dry-run) endpoint. For each query in the composite query, returns the underlying ClickHouse statement(s) it renders to without executing them (one per PromQL metric selector; exactly one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN ESTIMATE and granule analysis attached per statement when requested.")
return nil
}
// QueryPreview is the dry-run result for a single query, keyed by query name
// in QueryRangePreviewResponse.CompositeQuery.
type QueryPreview struct {
// Valid is the headline verdict for this query: true when it previewed
// without error, false when Error is set. It is always present (derived from
// Error at marshal time) so an agent can branch on a single boolean instead
// of testing for the presence of the error object.
Valid bool `json:"valid"`
// Error describes why this query is invalid or could not be previewed; nil
// when the query previewed successfully. It is the structured form
// (code, message, and — when available — suggestions and invalidReferences)
// so an agent can act on it programmatically instead of parsing a string.
Error error `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"`
// SelectivityScore is the headline selectivity for this query: the percentage
// (0-100) of candidate granules eliminated by partition, primary-key, and
// skip-index pruning before any data is read (higher = less data read). It is
// the minimum of the per-statement Statements[].Granules.SkipScore values —
// the least-selective (worst) underlying statement, which dominates cost.
// Returned only when the granules analysis ran (?granules=true or ?verbose=true)
// and at least one statement reads a MergeTree table. Paired with
// MagnitudeScore as the two headline score axes.
SelectivityScore *float64 `json:"selectivityScore,omitempty"`
// MagnitudeScore is the headline *cost* for this query (0-100; higher = less
// data scanned = cheaper), a separate axis from SelectivityScore. Selectivity
// is how good the index pruning *ratio* is, while MagnitudeScore reflects the
// *absolute* rows the query would scan (from EXPLAIN ESTIMATE), since a query
// can prune 99% of granules and still scan billions of rows on a huge table.
// Derived on a log scale from the estimated rows of the heaviest statement.
// Returned only when the estimate analysis ran (?estimate=true or ?verbose=true)
// and at least one statement has an estimate. The two scores are kept separate
// (not fused) so a caller can see which axis — selectivity or magnitude — is
// the problem.
MagnitudeScore *float64 `json:"magnitudeScore,omitempty"`
// Statements are the underlying ClickHouse statement(s) this query renders to,
// in execution order. Builder, ClickHouse SQL, and trace-operator queries
// render exactly one; a PromQL query renders one per metric selector (the
// Prometheus engine issues a statement per selector). Empty for a
// validation-only preview, a query that failed to render (see Error), or one
// that resolves to no data (a fully-missing metric, see Warnings).
Statements []PreviewStatement `json:"statements,omitempty"`
}
// PreviewStatement is one rendered ClickHouse statement the query will execute,
// with its bound args and — when requested — its EXPLAIN ESTIMATE (Estimate) and
// granule breakdown (Granules). The query/args field names follow the
// OpenTelemetry db.statement.* convention so an agent consuming the dry-run sees
// the same keys it would on a span.
type PreviewStatement struct {
Query string `json:"db.statement.query"`
Args []any `json:"db.statement.args,omitempty"`
// Estimate is the parsed ClickHouse EXPLAIN ESTIMATE output, set only for
// ?estimate=true (or ?verbose=true): one entry per table the statement reads,
// each with the parts/rows/marks ClickHouse estimates it will scan. Parsed
// into a struct (rather than the raw tab-separated table) so an agent can read
// the absolute cost estimate programmatically — it complements the
// ratio-based Granules.
Estimate []EstimateEntry `json:"estimate,omitempty"`
// Granules is the parsed granule-skip breakdown for this statement (candidate
// vs. surviving granules and the resulting skip score). Populated only for
// ?granules=true (or ?verbose=true) when the statement reads a MergeTree
// table, so an agent can see why a statement is (un)selective, not just the
// headline score.
Granules *Granules `json:"granules,omitempty"`
}
// EstimateEntry is ClickHouse's EXPLAIN ESTIMATE for one table the statement
// reads: the parts, rows, and marks it estimates it will scan. Unlike Granules
// (a pruning ratio), these are absolute counts, so they convey how much data a
// statement touches in real terms.
type EstimateEntry struct {
Database string `json:"database"`
Table string `json:"table"`
Parts int64 `json:"parts"`
Rows int64 `json:"rows"`
Marks int64 `json:"marks"`
}
// Granules is the granule-skip breakdown for one rendered statement, parsed from
// ClickHouse's `EXPLAIN json = 1, indexes = 1` index analysis. Granules are the
// unit of read in a MergeTree table; the fewer that survive pruning, the less
// data the query reads. Summed across every ReadFromMergeTree node in the plan
// so a multi-read statement is scored as a whole.
type Granules struct {
// Initial is the candidate granules before any pruning.
Initial int64 `json:"initial"`
// Selected is the granules surviving partition/primary-key/skip-index pruning
// — the ones the query would actually read.
Selected int64 `json:"selected"`
// Skipped is Initial - Selected: granules eliminated before any read.
Skipped int64 `json:"skipped"`
// SkipScore is 100 * Skipped / Initial, rounded to two decimals (0-100;
// higher = more selective).
SkipScore float64 `json:"skipScore"`
// Reads is the raw per-read index-pruning trace behind the aggregate above:
// one entry per ReadFromMergeTree node in the plan, each listing the index
// steps in the order ClickHouse applies them. It shows *which* index did the
// pruning and which did nothing — a step whose selected == initial pruned no
// granules (its index isn't engaging), and a read still selecting many
// granules after every step is a candidate for a new index. Empty when the
// plan exposes no MergeTree index analysis.
Reads []MergeTreeRead `json:"reads,omitempty"`
}
// MergeTreeRead is the index-pruning funnel for one ReadFromMergeTree node — one
// physical read of one table. The Steps run in sequence, so each step's Initial*
// matches the previous step's Selected*: the list reads as a funnel from
// candidate parts/granules down to what survives and is actually read.
type MergeTreeRead struct {
// Table is the table this node reads, e.g. "signoz_logs.logs_v2".
Table string `json:"table"`
// Steps are the index steps applied to this read, in execution order.
Steps []IndexStep `json:"steps"`
}
// IndexStep is one index applied during a MergeTree read, with the parts and
// granules entering it (Initial*) and surviving it (Selected*). Type is the
// ClickHouse index kind (MinMax, Partition, PrimaryKey, or Skip); Name is set
// for skip indexes; Keys/Condition describe what it matched on.
type IndexStep struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
Keys []string `json:"keys,omitempty"`
Condition string `json:"condition,omitempty"`
InitialParts int64 `json:"initialParts"`
SelectedParts int64 `json:"selectedParts"`
InitialGranules int64 `json:"initialGranules"`
SelectedGranules int64 `json:"selectedGranules"`
}
// MarshalJSON renders Error as the structured error form (code, message and,
// when present, suggestions/invalidReferences) instead of the default {} that a
// bare error interface produces, so an agent consuming the dry-run can act on it
// programmatically.
func (p QueryPreview) MarshalJSON() ([]byte, error) {
type alias QueryPreview
out := struct {
alias
Error *errors.JSON `json:"error,omitempty"`
}{alias: alias(p)}
out.alias.Error = nil
// Derive the verdict from the error so callers can't desync the two.
out.alias.Valid = p.Error == nil
if p.Error != nil {
out.Error = errors.AsJSON(p.Error)
}
return json.Marshal(out)
}
var _ jsonschema.Preparer = &QueryRangeResponse{}
// PrepareJSONSchema adds description to the QueryRangeResponse schema.

View File

@@ -535,6 +535,68 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
return nil
}
// ValidateRequestScope validates request-level invariants — time range,
// request type, the raw/trace metric-query restriction, non-empty composite
// query, unique builder query names, and not-all-disabled — WITHOUT validating
// individual query specs, and returns the ValidationOptions for the request
// type. The dry-run/preview path uses this so that per-query spec errors can be
// attributed to each query (via QueryEnvelope.Validate) instead of aborting the
// whole request on the first one, the way Validate does. The normal execution
// path keeps using the fail-fast Validate.
func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
if r.RequestType != RequestTypeRawStream && r.Start >= r.End {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "start time must be before end time")
}
var opts []ValidationOption
switch r.RequestType {
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar:
opts = GetValidationOptions(r.RequestType)
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
WithAdditional("Valid request types are: raw, timeseries, scalar")
}
if r.RequestType == RequestTypeRaw || r.RequestType == RequestTypeRawStream || r.RequestType == RequestTypeTrace {
for _, envelope := range r.CompositeQuery.Queries {
if envelope.GetSignal() == telemetrytypes.SignalMetrics {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "raw request type is not supported for metric queries")
}
}
}
if len(r.CompositeQuery.Queries) == 0 {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "at least one query is required")
}
// Builder query names must be unique across the composite query.
queryNames := make(map[string]bool)
for _, envelope := range r.CompositeQuery.Queries {
if envelope.Type == QueryTypeBuilder || envelope.Type == QueryTypeSubQuery {
name := envelope.GetQueryName()
if name != "" {
if queryNames[name] {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "duplicate query name '%s'", name)
}
queryNames[name] = true
}
}
}
if err := r.validateAllQueriesNotDisabled(); err != nil {
return nil, err
}
return opts, nil
}
// Validate validates a single query envelope's spec. It is the per-query
// counterpart to QueryRangeRequest.ValidateRequestScope, used by the dry-run to
// report each query's structural error independently.
func (e QueryEnvelope) Validate(opts ...ValidationOption) error {
return validateQueryEnvelope(e, opts...)
}
// validateAllQueriesNotDisabled validates that at least one query in the composite query is enabled.
func (r *QueryRangeRequest) validateAllQueriesNotDisabled() error {
for _, envelope := range r.CompositeQuery.Queries {