Compare commits

...

3 Commits

Author SHA1 Message Date
Srikanth Chekuri
5a80c6f6d5 Merge branch 'main' into scalar-enfore-qb 2026-07-25 23:59:54 +05:30
srikanthccv
507a5a9a3d fix: reject invalid v2 dashboard bodies instead of migrating them as v1 2026-07-25 23:59:00 +05:30
srikanthccv
207f36d357 fix: enforce scalar response contract for promql and metric builder queries 2026-07-25 22:12:12 +05:30
11 changed files with 409 additions and 27 deletions

View File

@@ -40,20 +40,23 @@ func (handler *handler) CreateV2(rw http.ResponseWriter, r *http.Request) {
return
}
var shadow struct {
SchemaVersion string `json:"schemaVersion"`
Version string `json:"version"`
}
_ = json.Unmarshal(body, &shadow)
var req dashboardtypes.PostableDashboardV2
if err := binding.JSON.BindBody(bytes.NewReader(body), &req); err != nil {
// Fallback: the body may be a legacy v1 dashboard. Migrate it to v2 (same
// v4→v5 + v1→v2 pass the bulk migration runs) and retry; if it isn't a
// convertible v1 payload either, surface the original v2 binding error.
if shadow.SchemaVersion == "" && shadow.Version != "" {
var data map[string]any
if json.Unmarshal(body, &data) != nil {
render.Error(rw, err)
if err := json.Unmarshal(body, &data); err != nil {
render.Error(rw, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", err.Error()))
return
}
storable := dashboardtypes.StorableDashboard{Data: data, OrgID: orgID}
transition.NewDashboardMigrateV5(handler.providerSettings.Logger, nil, nil).Migrate(ctx, storable.Data)
v2, convErr := storable.ConvertV1ToV2()
if convErr != nil {
v2, err := storable.ConvertV1ToV2()
if err != nil {
render.Error(rw, err)
return
}
@@ -63,6 +66,9 @@ func (handler *handler) CreateV2(rw http.ResponseWriter, r *http.Request) {
Tags: tagtypes.NewPostableTagsFromTags(v2.Tags),
Spec: v2.Spec,
}
} else if err := binding.JSON.BindBody(bytes.NewReader(body), &req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.CreateV2(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.IdentityID()), dashboardtypes.SourceUser, req)

View File

@@ -123,6 +123,10 @@ func newPromqlQuery(
}
func (q *promqlQuery) Fingerprint() string {
if q.requestType != qbv5.RequestTypeTimeSeries {
return ""
}
query, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
if err != nil {
q.logger.ErrorContext(context.TODO(), "failed render template variables", slog.String("query", q.query.Query))
@@ -361,16 +365,37 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
warnings, _ := res.Warnings.AsStrings(query, 10, 0)
return &qbv5.Result{
Type: q.requestType,
Value: &qbv5.TimeSeriesData{
QueryName: q.query.Name,
Aggregations: []*qbv5.AggregationBucket{
{
Series: series,
},
tsData := &qbv5.TimeSeriesData{
QueryName: q.query.Name,
Aggregations: []*qbv5.AggregationBucket{
{
Series: series,
},
},
}
var payload any = tsData
// Scalar requests must return scalar data; reduce each series to its
// last point. This approximates an instant query evaluated at the end
// of the window: the final range-eval step sees the same samples an
// instant query at that timestamp would, except a series that went
// stale mid-window still surfaces its most recent value instead of
// dropping out of the result.
// TODO(srikanthccv): "last" mirrors the instant-query semantics we
// have always followed for PromQL scalar requests, but it should be
// configurable on the PromQuery spec just like the builder reduceTo.
if q.requestType == qbv5.RequestTypeScalar {
for _, aggBucket := range tsData.Aggregations {
for i, s := range aggBucket.Series {
aggBucket.Series[i] = qbv5.FunctionReduceTo(s, qbv5.ReduceToLast)
}
}
payload = convertTimeSeriesDataToScalar(tsData, q.query.Name)
}
return &qbv5.Result{
Type: q.requestType,
Value: payload,
Warnings: warnings,
// TODO: map promql stats?
}, nil

View File

@@ -322,6 +322,10 @@ func (ReduceTo) Enum() []any {
}
}
func (r ReduceTo) IsValid() bool {
return slices.ContainsFunc(r.Enum(), func(v any) bool { return v == r })
}
// FunctionReduceTo applies the reduceTo operator to a time series and returns a new series with the reduced value
// reduceTo can be one of: last, sum, avg, min, max, count, median
// if reduceTo is not recognized, the function returns the original series.

View File

@@ -78,6 +78,7 @@ type validationConfig struct {
skipSelectFieldValidation bool
skipGroupByValidation bool
withTimestampGroupByValidation bool
withReduceToValidation bool
}
func applyValidationOptions(opts []ValidationOption) validationConfig {
@@ -143,6 +144,15 @@ func WithTimestampGroupByValidation() ValidationOption {
}
}
// WithReduceToValidation enables validation that metric aggregations carry a
// valid reduceTo operator. Used for scalar request types, where the time
// series produced by a metric query must be reduced to a single value.
func WithReduceToValidation() ValidationOption {
return func(cfg *validationConfig) {
cfg.withReduceToValidation = true
}
}
// Validate performs preliminary validation on QueryBuilderQuery.
func (q *QueryBuilderQuery[T]) Validate(opts ...ValidationOption) error {
cfg := applyValidationOptions(opts)
@@ -279,6 +289,29 @@ func (q *QueryBuilderQuery[T]) validateAggregations(cfg validationConfig) error
"invalid space aggregation, should be one of the following: [`sum`, `avg`, `min`, `max`, `count`, `p50`, `p75`, `p90`, `p95`, `p99`]",
)
}
if cfg.withReduceToValidation && !v.ReduceTo.IsValid() {
aggId := fmt.Sprintf("aggregation #%d", i+1)
if q.Name != "" {
aggId = fmt.Sprintf("aggregation #%d in query '%s'", i+1, q.Name)
}
if v.ReduceTo == ReduceToUnknown {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"reduceTo is required for %s for the scalar request type",
aggId,
).WithAdditional(
"Metric queries produce a time series; scalar requests must specify how to reduce it to a single value. Valid values are: sum, count, avg, min, max, last, median",
)
}
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid reduceTo `%s` for %s",
v.ReduceTo.StringValue(),
aggId,
).WithAdditional(
"Valid values are: sum, count, avg, min, max, last, median",
)
}
case TraceAggregation:
if v.Expression == "" {
aggId := fmt.Sprintf("aggregation #%d", i+1)
@@ -790,7 +823,7 @@ func GetValidationOptions(requestType RequestType) []ValidationOption {
case RequestTypeTimeSeries:
return []ValidationOption{WithSkipSelectFieldValidation(), WithTimestampGroupByValidation()}
case RequestTypeScalar:
return []ValidationOption{WithSkipSelectFieldValidation()}
return []ValidationOption{WithSkipSelectFieldValidation(), WithReduceToValidation()}
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace:
return []ValidationOption{WithSkipAggregationValidation(), WithSkipHavingValidation(), WithSkipAggregationOrderBy(), WithSkipGroupByValidation()}
default:

View File

@@ -6,6 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func contains(s, substr string) bool {
@@ -769,6 +770,100 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
},
wantErr: false,
},
{
name: "scalar request with metric query without reduceTo should return error",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeScalar,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []MetricAggregation{
{MetricName: "test_metric", SpaceAggregation: metrictypes.SpaceAggregationSum},
},
},
},
},
},
},
wantErr: true,
errMsg: "reduceTo is required",
},
{
name: "scalar request with metric query with invalid reduceTo should return error",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeScalar,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []MetricAggregation{
{MetricName: "test_metric", SpaceAggregation: metrictypes.SpaceAggregationSum, ReduceTo: ReduceTo{valuer.NewString("p99")}},
},
},
},
},
},
},
wantErr: true,
errMsg: "invalid reduceTo",
},
{
name: "scalar request with metric query with reduceTo should pass",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeScalar,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []MetricAggregation{
{MetricName: "test_metric", SpaceAggregation: metrictypes.SpaceAggregationSum, ReduceTo: ReduceToLast},
},
},
},
},
},
},
wantErr: false,
},
{
name: "timeseries request with metric query without reduceTo should pass",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []MetricAggregation{
{MetricName: "test_metric", SpaceAggregation: metrictypes.SpaceAggregationSum},
},
},
},
},
},
},
wantErr: false,
},
}
for _, tt := range tests {

View File

@@ -693,13 +693,17 @@ def build_metrics_aggregation(
time_aggregation: str,
space_aggregation: str,
temporality: str = "cumulative",
reduce_to: str | None = None,
) -> dict:
return {
agg = {
"metricName": metric_name,
"temporality": temporality,
"timeAggregation": time_aggregation,
"spaceAggregation": space_aggregation,
}
if reduce_to:
agg["reduceTo"] = reduce_to
return agg
def get_scalar_table_data(response_json: dict) -> list[list[Any]]:

View File

@@ -48,7 +48,7 @@ def test_metrics_group_by_context_collapse(
querier.build_scalar_query(
name="A",
signal="metrics",
aggregations=[querier.build_metrics_aggregation(METRIC, "latest", "sum", "unspecified")],
aggregations=[querier.build_metrics_aggregation(METRIC, "latest", "sum", "unspecified", reduce_to="last")],
group_by=[querier.build_group_by_field("region", "string", ctx)],
)
],
@@ -96,7 +96,7 @@ def test_metrics_filter_label_context(
querier.build_scalar_query(
name="A",
signal="metrics",
aggregations=[querier.build_metrics_aggregation(METRIC, "latest", "sum", "unspecified")],
aggregations=[querier.build_metrics_aggregation(METRIC, "latest", "sum", "unspecified", reduce_to="last")],
group_by=[querier.build_group_by_field("region", "string", "")],
filter_expression=expr,
)
@@ -116,7 +116,7 @@ def test_metrics_filter_label_context(
querier.build_scalar_query(
name="A",
signal="metrics",
aggregations=[querier.build_metrics_aggregation(METRIC, "latest", "sum", "unspecified")],
aggregations=[querier.build_metrics_aggregation(METRIC, "latest", "sum", "unspecified", reduce_to="last")],
filter_expression='resource.region = "us"',
)
],
@@ -157,7 +157,7 @@ def test_metrics_group_by_unknown_label(
querier.build_scalar_query(
name="A",
signal="metrics",
aggregations=[querier.build_metrics_aggregation(METRIC, "latest", "sum", "unspecified")],
aggregations=[querier.build_metrics_aggregation(METRIC, "latest", "sum", "unspecified", reduce_to="last")],
group_by=[querier.build_group_by_field("does_not_exist_label", "string", "attribute")],
)
],
@@ -201,7 +201,7 @@ def test_metrics_filter_unknown_label_matches_nothing(
querier.build_scalar_query(
name="A",
signal="metrics",
aggregations=[querier.build_metrics_aggregation(METRIC, "latest", "sum", "unspecified")],
aggregations=[querier.build_metrics_aggregation(METRIC, "latest", "sum", "unspecified", reduce_to="last")],
filter_expression='does_not_exist_label = "x"',
)
],
@@ -247,7 +247,7 @@ def test_metrics_full_text_filter_does_not_error(
querier.build_scalar_query(
name="A",
signal="metrics",
aggregations=[querier.build_metrics_aggregation(METRIC, "latest", "sum", "unspecified")],
aggregations=[querier.build_metrics_aggregation(METRIC, "latest", "sum", "unspecified", reduce_to="last")],
filter_expression=expr,
)
],

View File

@@ -44,7 +44,7 @@ def build_metrics_query(
order_by: list[tuple[str, str]] | None = None,
limit: int | None = None,
) -> dict:
aggs = [querier.build_metrics_aggregation(metric_name, time_aggregation, space_aggregation, "unspecified")]
aggs = [querier.build_metrics_aggregation(metric_name, time_aggregation, space_aggregation, "unspecified", reduce_to="last")]
gb = [querier.build_group_by_field(f, "string", "attribute") for f in group_by] if group_by else None
order = [querier.build_order_by(name, direction) for name, direction in order_by] if order_by else None

View File

@@ -70,3 +70,39 @@ def test_metrics_scalar_reduce_to_modes(
assert response.json()["status"] == "success"
data = get_scalar_table_data(response.json())
assert_scalar_result_order(data, [("service-a", expected)], f"metrics reduceTo={reduce_to}")
def test_metrics_scalar_missing_reduce_to_rejected(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_scalar_query(
name="A",
signal="metrics",
aggregations=[build_metrics_aggregation("test.reduce.metric", "latest", "sum", "unspecified")],
)
response = make_scalar_query_request(signoz, token, now, [query])
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert "reduceTo is required" in response.json()["error"]["message"], response.text
def test_metrics_scalar_invalid_reduce_to_rejected(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
aggregation = build_metrics_aggregation("test.reduce.metric", "latest", "sum", "unspecified")
aggregation["reduceTo"] = "p99"
query = build_scalar_query(name="A", signal="metrics", aggregations=[aggregation])
response = make_scalar_query_request(signoz, token, now, [query])
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert "invalid reduceTo" in response.json()["error"]["message"], response.text

View File

@@ -55,7 +55,7 @@ def test_metrics_scalar_space_aggregations(
query = build_scalar_query(
name="A",
signal="metrics",
aggregations=[build_metrics_aggregation("test.metric", "latest", space_aggregation, "unspecified")],
aggregations=[build_metrics_aggregation("test.metric", "latest", space_aggregation, "unspecified", reduce_to="last")],
)
response = make_scalar_query_request(signoz, token, now, [query])
@@ -96,7 +96,7 @@ def test_metrics_scalar_having(
query = build_scalar_query(
name="A",
signal="metrics",
aggregations=[build_metrics_aggregation("test.metric", "latest", "sum", "unspecified")],
aggregations=[build_metrics_aggregation("test.metric", "latest", "sum", "unspecified", reduce_to="last")],
group_by=[build_group_by_field("service.name", "string", "attribute")],
having_expression=having_expression,
)

View File

@@ -0,0 +1,179 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.querier import (
RequestType,
find_named_result,
get_scalar_table_data,
make_query_request,
make_scalar_query_request,
)
# PromQL scalar contract: a scalar request must return scalar data
# (columns + data rows), not time series data. PromQL has no reduceTo, so
# each series is reduced with last-value semantics.
METRIC = "test_promql_scalar_metric"
def build_promql_query(query: str, name: str = "A", step: int = 60) -> dict:
return {"type": "promql", "spec": {"name": name, "query": query, "disabled": False, "step": step}}
def insert_gauge_samples(insert_metrics: Callable[[list[Metrics]], None], base: datetime) -> None:
samples = []
for service, values in [("svc-a", (10.0, 20.0, 30.0)), ("svc-b", (40.0, 50.0, 60.0))]:
for i, value in enumerate(values):
samples.append(
Metrics(
metric_name=METRIC,
labels={"service": service},
timestamp=base - timedelta(seconds=121 - 60 * i),
value=value,
temporality="Unspecified",
type_="Gauge",
is_monotonic=False,
)
)
insert_metrics(samples)
def test_promql_scalar_returns_scalar_data(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_gauge_samples(insert_metrics, now)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
[build_promql_query(f"sum by (service) ({METRIC})")],
request_type=RequestType.SCALAR,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
result = find_named_result(response.json()["data"]["data"]["results"], "A")
assert result is not None, response.text
assert "aggregations" not in result, f"scalar result must not carry time series aggregations: {result}"
assert "columns" in result and "data" in result, result
group_cols = [c for c in result["columns"] if c["columnType"] == "group"]
agg_cols = [c for c in result["columns"] if c["columnType"] == "aggregation"]
assert [c["name"] for c in group_cols] == ["service"], result["columns"]
assert [c["name"] for c in agg_cols] == ["__result_0"], result["columns"]
rows = sorted(result["data"], key=lambda r: r[0])
assert rows == [["svc-a", 30.0], ["svc-b", 60.0]], rows
def test_promql_scalar_format_for_ui(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_gauge_samples(insert_metrics, now)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_promql_query(f"sum by (service) ({METRIC})")],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
data = get_scalar_table_data(response.json())
assert data == [["svc-b", 60.0], ["svc-a", 30.0]], data
def test_promql_timeseries_unaffected(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_gauge_samples(insert_metrics, now)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
[build_promql_query(f"sum by (service) ({METRIC})")],
request_type=RequestType.TIME_SERIES,
)
assert response.status_code == HTTPStatus.OK, response.text
result = find_named_result(response.json()["data"]["data"]["results"], "A")
assert result is not None, response.text
assert "aggregations" in result, result
series = result["aggregations"][0]["series"]
assert len(series) == 2, series
last_by_service = {s["labels"][0]["value"]: s["values"][-1]["value"] for s in series}
assert last_by_service == {"svc-a": 30.0, "svc-b": 60.0}, last_by_service
def test_promql_scalar_not_served_cached_time_series(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
base = now - timedelta(minutes=10)
insert_gauge_samples(insert_metrics, base)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((base - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(base.timestamp() * 1000)
queries = [build_promql_query(f"sum by (service) ({METRIC})")]
ts_response = make_query_request(
signoz,
token,
start_ms,
end_ms,
queries,
request_type=RequestType.TIME_SERIES,
no_cache=False,
)
assert ts_response.status_code == HTTPStatus.OK, ts_response.text
scalar_response = make_query_request(
signoz,
token,
start_ms,
end_ms,
queries,
request_type=RequestType.SCALAR,
no_cache=False,
)
assert scalar_response.status_code == HTTPStatus.OK, scalar_response.text
result = find_named_result(scalar_response.json()["data"]["data"]["results"], "A")
assert result is not None, scalar_response.text
assert "aggregations" not in result, f"scalar result served cached time series data: {result}"
rows = sorted(result["data"], key=lambda r: r[0])
assert rows == [["svc-a", 30.0], ["svc-b", 60.0]], rows