Compare commits

...

23 Commits

Author SHA1 Message Date
Naman Verma
79143be510 Revert "Merge branch 'nv/v2-public-dashboard' into nv/dashboard-create-aggregation"
This reverts commit e91785bfe3, reversing
changes made to fe838a3464.
2026-06-30 21:48:14 +05:30
Naman Verma
e91785bfe3 Merge branch 'nv/v2-public-dashboard' into nv/dashboard-create-aggregation 2026-06-30 21:41:37 +05:30
Naman Verma
fe838a3464 Merge branch 'main' into nv/dashboard-create-aggregation 2026-06-30 21:40:48 +05:30
Naman Verma
ff22facdd6 Merge branch 'main' into nv/v2-public-dashboard 2026-06-30 21:40:36 +05:30
Naman Verma
875432d7ec test: add integration test 2026-06-30 21:34:17 +05:30
Naman Verma
5f1e7a3a53 chore: remove unused err type 2026-06-30 19:15:31 +05:30
Naman Verma
6a4629a418 fix: handle multi aggregations in public dashboard panels 2026-06-30 19:03:50 +05:30
Naman Verma
72a6ca6516 Merge branch 'main' into nv/v2-public-dashboard 2026-06-25 20:07:16 +05:30
Naman Verma
bbd5cc380e Merge branch 'main' into nv/v2-public-dashboard 2026-06-25 02:02:16 +05:30
Naman Verma
6cd9b5bbd6 Merge branch 'main' into nv/v2-public-dashboard 2026-06-18 15:37:15 +05:30
Naman Verma
13f6c232a1 Merge branch 'main' into nv/v2-public-dashboard 2026-06-18 11:47:24 +05:30
Naman Verma
dac1489294 Merge branch 'main' into nv/v2-public-dashboard 2026-06-17 07:29:31 +05:30
Naman Verma
1d98e9ebf6 Merge branch 'main' into nv/v2-public-dashboard 2026-06-16 12:35:00 +05:30
Naman Verma
15e99e43ff test: add integration tests for new v2 public apis 2026-06-16 12:21:50 +05:30
Naman Verma
8c766f8c10 fix: generate api specs 2026-06-16 11:45:22 +05:30
Naman Verma
99b32f00b9 fix: add fill gaps to query 2026-06-16 02:30:59 +05:30
Naman Verma
76f8646c69 test: unit tests for GetPanelQuery 2026-06-16 02:18:01 +05:30
Naman Verma
28c00e298a chore: rename method name 2026-06-16 02:01:40 +05:30
Naman Verma
4592b12256 fix: remove fields that v1 also removes when redacting 2026-06-16 01:53:33 +05:30
Naman Verma
b990d40c5f chore: trim comments 2026-06-16 00:23:41 +05:30
Naman Verma
95a0d7c035 fix: fill fields that were in the data blob in v1 2026-06-15 23:04:21 +05:30
Naman Verma
e678728c61 fix: remove duplicate call to GetDashboardByPublicIDV2 in GetPublicWidgetQueryRangeV2 2026-06-15 22:31:45 +05:30
Naman Verma
42d3e7e0e4 feat: add first draft of v2 public dashboard apis 2026-06-15 15:00:44 +05:30
5 changed files with 548 additions and 117 deletions

View File

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type migrateCommon struct {
@@ -23,119 +24,10 @@ func NewMigrateCommon(logger *slog.Logger) *migrateCommon {
}
}
// WrapInV5Envelope delegates to querybuildertypesv5.WrapInV5Envelope; the
// transform is stateless and shared with the v1→v2 dashboard conversion.
func (migration *migrateCommon) WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
// Create a properly structured v5 query
v5Query := map[string]any{
"name": name,
"disabled": queryMap["disabled"],
"legend": queryMap["legend"],
}
if name != queryMap["expression"] {
// formula
queryType = "builder_formula"
v5Query["expression"] = queryMap["expression"]
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// Add signal based on data source
if dataSource, ok := queryMap["dataSource"].(string); ok {
switch dataSource {
case "traces":
v5Query["signal"] = "traces"
case "logs":
v5Query["signal"] = "logs"
case "metrics":
v5Query["signal"] = "metrics"
}
}
if stepInterval, ok := queryMap["stepInterval"]; ok {
v5Query["stepInterval"] = stepInterval
}
if aggregations, ok := queryMap["aggregations"]; ok {
v5Query["aggregations"] = aggregations
}
if filter, ok := queryMap["filter"]; ok {
v5Query["filter"] = filter
}
// Copy groupBy with proper structure
if groupBy, ok := queryMap["groupBy"].([]any); ok {
v5GroupBy := make([]any, len(groupBy))
for i, gb := range groupBy {
if gbMap, ok := gb.(map[string]any); ok {
v5GroupBy[i] = map[string]any{
"name": gbMap["key"],
"fieldDataType": gbMap["dataType"],
"fieldContext": gbMap["type"],
}
}
}
v5Query["groupBy"] = v5GroupBy
}
// Copy orderBy with proper structure
if orderBy, ok := queryMap["orderBy"].([]any); ok {
v5OrderBy := make([]any, len(orderBy))
for i, ob := range orderBy {
if obMap, ok := ob.(map[string]any); ok {
v5OrderBy[i] = map[string]any{
"key": map[string]any{
"name": obMap["columnName"],
"fieldDataType": obMap["dataType"],
"fieldContext": obMap["type"],
},
"direction": obMap["order"],
}
}
}
v5Query["order"] = v5OrderBy
}
// Copy selectColumns as selectFields
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
v5SelectFields := make([]any, len(selectColumns))
for i, col := range selectColumns {
if colMap, ok := col.(map[string]any); ok {
v5SelectFields[i] = map[string]any{
"name": colMap["key"],
"fieldDataType": colMap["dataType"],
"fieldContext": colMap["type"],
}
}
}
v5Query["selectFields"] = v5SelectFields
}
// Copy limit and offset
if limit, ok := queryMap["limit"]; ok {
v5Query["limit"] = limit
}
if offset, ok := queryMap["offset"]; ok {
v5Query["offset"] = offset
}
if having, ok := queryMap["having"]; ok {
v5Query["having"] = having
}
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
return querybuildertypesv5.WrapInV5Envelope(name, queryMap, queryType)
}
func (mc *migrateCommon) updateQueryData(ctx context.Context, queryData map[string]any, version, widgetType string) bool {

View File

@@ -7,7 +7,6 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -406,27 +405,34 @@ func (dashboard *Dashboard) GetWidgetQuery(startTime, endTime, widgetIndex uint6
widgetData := data.Widgets[widgetIndex]
switch widgetData.Query.QueryType {
case "builder":
migrate := transition.NewMigrateCommon(logger)
isRawRequest := dashboard.getQueryRequestTypeFromPanelType(widgetData.PanelTypes) == querybuildertypesv5.RequestTypeRaw
for _, query := range widgetData.Query.Builder.QueryData {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_query"))
// build aggregations the same way the frontend does before hitting the query
// range API; raw requests carry no aggregations.
if isRawRequest {
delete(query, "aggregations")
} else {
query["aggregations"] = querybuildertypesv5.CreateAggregation(query, widgetData.PanelTypes)
}
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_query"))
}
for _, query := range widgetData.Query.Builder.QueryFormulas {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_formula"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_formula"))
}
for _, query := range widgetData.Query.Builder.QueryTraceOperator {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
}
case "clickhouse_sql":
for _, query := range widgetData.Query.ClickhouseSQL {

View File

@@ -0,0 +1,214 @@
package querybuildertypesv5
import (
"regexp"
"strings"
)
// WrapInV5Envelope translates a single v4 builder query/formula map into a
// v5 query envelope ({"type": ..., "spec": ...}). It is a pure shape transform
// over untyped maps: v4 builder field names (groupBy/orderBy/selectColumns/
// dataSource) are rewritten to their v5 equivalents and a `signal` is derived
// from the data source. queryType selects the envelope type, except a formula
// (detected when name != queryMap["expression"]) is always emitted as
// "builder_formula".
//
// Migration code (pkg/transition) and the v1→v2 dashboard conversion both
// produce v5 envelopes, so this lives here with the v5 query types rather than
// in an infra-level package.
func WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
// Create a properly structured v5 query
v5Query := map[string]any{
"name": name,
"disabled": queryMap["disabled"],
"legend": queryMap["legend"],
}
if name != queryMap["expression"] {
// formula
queryType = "builder_formula"
v5Query["expression"] = queryMap["expression"]
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// Add signal based on data source
if dataSource, ok := queryMap["dataSource"].(string); ok {
switch dataSource {
case "traces":
v5Query["signal"] = "traces"
case "logs":
v5Query["signal"] = "logs"
case "metrics":
v5Query["signal"] = "metrics"
}
}
if stepInterval, ok := queryMap["stepInterval"]; ok {
v5Query["stepInterval"] = stepInterval
}
if aggregations, ok := queryMap["aggregations"]; ok {
v5Query["aggregations"] = aggregations
}
if filter, ok := queryMap["filter"]; ok {
v5Query["filter"] = filter
}
// Copy groupBy with proper structure
if groupBy, ok := queryMap["groupBy"].([]any); ok {
v5GroupBy := make([]any, len(groupBy))
for i, gb := range groupBy {
if gbMap, ok := gb.(map[string]any); ok {
v5GroupBy[i] = map[string]any{
"name": gbMap["key"],
"fieldDataType": gbMap["dataType"],
"fieldContext": gbMap["type"],
}
}
}
v5Query["groupBy"] = v5GroupBy
}
// Copy orderBy with proper structure
if orderBy, ok := queryMap["orderBy"].([]any); ok {
v5OrderBy := make([]any, len(orderBy))
for i, ob := range orderBy {
if obMap, ok := ob.(map[string]any); ok {
v5OrderBy[i] = map[string]any{
"key": map[string]any{
"name": obMap["columnName"],
"fieldDataType": obMap["dataType"],
"fieldContext": obMap["type"],
},
"direction": obMap["order"],
}
}
}
v5Query["order"] = v5OrderBy
}
// Copy selectColumns as selectFields
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
v5SelectFields := make([]any, len(selectColumns))
for i, col := range selectColumns {
if colMap, ok := col.(map[string]any); ok {
v5SelectFields[i] = map[string]any{
"name": colMap["key"],
"fieldDataType": colMap["dataType"],
"fieldContext": colMap["type"],
}
}
}
v5Query["selectFields"] = v5SelectFields
}
// Copy limit and offset
if limit, ok := queryMap["limit"]; ok {
v5Query["limit"] = limit
}
if offset, ok := queryMap["offset"]; ok {
v5Query["offset"] = offset
}
if having, ok := queryMap["having"]; ok {
v5Query["having"] = having
}
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// aggregationExprRegexp matches a function-style aggregation like `count()` or
// `sum(field)` with an optional `as <alias>`, as the frontend's parseAggregations does.
var aggregationExprRegexp = regexp.MustCompile(`([a-zA-Z0-9_]+\([^)]*\))(?:\s*as\s+((?:'[^']*'|"[^"]*"|[a-zA-Z0-9_-]+)))?`)
// CreateAggregation builds the v5 aggregations for a stored builder query, mirroring
// createAggregation in the frontend's prepareQueryRangePayloadV5.ts. Metrics yield a
// single structured aggregation; logs/traces split their comma-separated expression into
// one aggregation per call, defaulting to count() when nothing parses.
func CreateAggregation(queryData map[string]any, panelType string) []any {
if queryData == nil {
return []any{}
}
if dataSource, _ := queryData["dataSource"].(string); dataSource == "metrics" {
var first map[string]any
if aggs, ok := queryData["aggregations"].([]any); ok && len(aggs) > 0 {
first, _ = aggs[0].(map[string]any)
}
attribute, _ := queryData["aggregateAttribute"].(map[string]any)
metric := map[string]any{}
setFirstNonEmpty(metric, "metricName", first["metricName"], attribute["key"])
setFirstNonEmpty(metric, "temporality", first["temporality"], attribute["temporality"])
setFirstNonEmpty(metric, "timeAggregation", first["timeAggregation"], queryData["timeAggregation"])
setFirstNonEmpty(metric, "spaceAggregation", first["spaceAggregation"], queryData["spaceAggregation"])
if panelType == "table" || panelType == "pie" || panelType == "value" {
setFirstNonEmpty(metric, "reduceTo", first["reduceTo"], queryData["reduceTo"])
}
return []any{metric}
}
aggs, ok := queryData["aggregations"].([]any)
if !ok || len(aggs) == 0 {
return []any{map[string]any{"expression": "count()"}}
}
result := []any{}
for _, agg := range aggs {
aggMap, _ := agg.(map[string]any)
expression, _ := aggMap["expression"].(string)
alias, _ := aggMap["alias"].(string)
parsed := parseAggregations(expression, alias)
if len(parsed) == 0 {
result = append(result, map[string]any{"expression": "count()"})
continue
}
result = append(result, parsed...)
}
return result
}
// parseAggregations extracts each function-style call from a (possibly comma-separated)
// aggregation expression, attaching the inline `as` alias or the fallback alias.
func parseAggregations(expression, fallbackAlias string) []any {
result := []any{}
for _, match := range aggregationExprRegexp.FindAllStringSubmatch(expression, -1) {
agg := map[string]any{"expression": match[1]}
if alias := match[2]; alias != "" {
agg["alias"] = strings.Trim(alias, `'"`)
} else if fallbackAlias != "" {
agg["alias"] = fallbackAlias
}
result = append(result, agg)
}
return result
}
// setFirstNonEmpty sets key to the first value that is neither nil nor "", mirroring the
// JS `a || b` fallback the frontend uses for the metric aggregation fields.
func setFirstNonEmpty(target map[string]any, key string, values ...any) {
for _, v := range values {
if v == nil {
continue
}
if s, ok := v.(string); ok && s == "" {
continue
}
target[key] = v
return
}
}

View File

@@ -0,0 +1,176 @@
package querybuildertypesv5
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCreateAggregation(t *testing.T) {
testCases := []struct {
description string
queryData map[string]any
panelType string
expectedOutput []any
}{
{
description: "nil query data yields no aggregations",
queryData: nil,
expectedOutput: []any{},
},
{
description: "single logs expression is left untouched",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "count()"}}},
expectedOutput: []any{map[string]any{"expression": "count()"}},
},
{
description: "comma separated trace expressions are split into one object each",
queryData: map[string]any{"dataSource": "traces", "aggregations": []any{map[string]any{"expression": "count(), sum(price)"}}},
expectedOutput: []any{
map[string]any{"expression": "count()"},
map[string]any{"expression": "sum(price)"},
},
},
{
description: "inline alias is preserved and unquoted",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "count() as 'total', sum(price) as revenue"}}},
expectedOutput: []any{
map[string]any{"expression": "count()", "alias": "total"},
map[string]any{"expression": "sum(price)", "alias": "revenue"},
},
},
{
description: "space separated expressions split with an unquoted alias on the first only",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "count() as cnt avg(code.lineno) "}}},
expectedOutput: []any{
map[string]any{"expression": "count()", "alias": "cnt"},
map[string]any{"expression": "avg(code.lineno)"},
},
},
{
description: "fallback alias is applied when expression has no inline alias",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "count()", "alias": "hits"}}},
expectedOutput: []any{map[string]any{"expression": "count()", "alias": "hits"}},
},
{
description: "commas inside function arguments do not split the expression",
queryData: map[string]any{"dataSource": "traces", "aggregations": []any{map[string]any{"expression": "countIf(day > 10, status)"}}},
expectedOutput: []any{map[string]any{"expression": "countIf(day > 10, status)"}},
},
{
description: "unparseable expression falls back to count()",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "not-an-aggregation"}}},
expectedOutput: []any{map[string]any{"expression": "count()"}},
},
{
description: "empty aggregations fall back to count()",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{}},
expectedOutput: []any{map[string]any{"expression": "count()"}},
},
{
description: "missing aggregations fall back to count()",
queryData: map[string]any{"dataSource": "traces"},
expectedOutput: []any{map[string]any{"expression": "count()"}},
},
{
description: "metric aggregation is built from the first aggregation",
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{
"metricName": "http_requests_total",
"temporality": "delta",
"timeAggregation": "rate",
"spaceAggregation": "sum",
}},
},
expectedOutput: []any{map[string]any{
"metricName": "http_requests_total",
"temporality": "delta",
"timeAggregation": "rate",
"spaceAggregation": "sum",
}},
},
{
description: "metric omits temporality when empty, matching the frontend `|| undefined`",
panelType: "table",
queryData: map[string]any{
"dataSource": "metrics",
"timeAggregation": "sum",
"spaceAggregation": "avg",
"temporality": "",
"reduceTo": "avg",
"aggregations": []any{map[string]any{
"metricName": "cpu_usage",
"temporality": "",
"timeAggregation": "sum",
"spaceAggregation": "avg",
"reduceTo": "avg",
}},
},
expectedOutput: []any{map[string]any{
"metricName": "cpu_usage",
"timeAggregation": "sum",
"spaceAggregation": "avg",
"reduceTo": "avg",
}},
},
{
description: "metric includes reduceTo for table/pie/value panels",
panelType: "table",
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{
"metricName": "http_requests_total",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "avg",
}},
},
expectedOutput: []any{map[string]any{
"metricName": "http_requests_total",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "avg",
}},
},
{
description: "metric drops reduceTo for other panels even when query data has it",
panelType: "graph",
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{
"metricName": "http_requests_total",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "avg",
}},
},
expectedOutput: []any{map[string]any{
"metricName": "http_requests_total",
"timeAggregation": "rate",
"spaceAggregation": "sum",
}},
},
{
description: "metric falls back to legacy aggregateAttribute and top-level fields",
queryData: map[string]any{
"dataSource": "metrics",
"aggregateAttribute": map[string]any{"key": "legacy_metric", "temporality": "cumulative"},
"timeAggregation": "avg",
"spaceAggregation": "max",
},
expectedOutput: []any{map[string]any{
"metricName": "legacy_metric",
"temporality": "cumulative",
"timeAggregation": "avg",
"spaceAggregation": "max",
}},
},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
assert.Equal(t, testCase.expectedOutput, CreateAggregation(testCase.queryData, testCase.panelType))
})
}
}

View File

@@ -1,3 +1,4 @@
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
@@ -8,6 +9,7 @@ from sqlalchemy import sql
from wiremock.resources.mappings import Mapping
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.logs import Logs
from fixtures.metrics import Metrics
from fixtures.types import Operation, SigNoz, TestContainerDocker
@@ -205,6 +207,147 @@ def test_public_dashboard_widget_query_range(
assert resp.status_code == HTTPStatus.BAD_REQUEST
def test_public_dashboard_widget_query_range_multi_aggregation(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
):
"""
A logs/traces widget stores several aggregations as one comma-separated expression
(e.g. "count(), sum(latency_ms)"). The public widget query path must split it into
one aggregation per call, mirroring the frontend, before handing it to the querier.
If the split does not happen the querier receives a single malformed aggregation and
the request fails - so a successful response with two aggregations proves the split.
"""
add_license(signoz, make_http_mocks, get_token)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Unique per-run service so the widget query only sees this run's logs.
service_name = f"multiagg-public-{uuid.uuid4()}"
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(
[
Logs(
timestamp=now - timedelta(minutes=5),
resources={"service.name": service_name},
attributes={"latency_ms": 100},
body="multi-agg log 1",
),
Logs(
timestamp=now - timedelta(minutes=3),
resources={"service.name": service_name},
attributes={"latency_ms": 200},
body="multi-agg log 2",
),
Logs(
timestamp=now - timedelta(minutes=1),
resources={"service.name": service_name},
attributes={"latency_ms": 300},
body="multi-agg log 3",
),
]
)
dashboard_req = {
"title": "Multi Aggregation Public Widget",
"description": "Comma-separated aggregations must be split on the public query path",
"version": "v5",
"widgets": [
{
"id": "b2c0a1d4-9f3e-4c2a-8a7b-1e2f3a4b5c6d",
"panelTypes": "graph",
"query": {
"builder": {
"queryData": [
{
"aggregations": [{"expression": "count(), sum(latency_ms)"}],
"dataSource": "logs",
"disabled": False,
"expression": "A",
"filter": {"expression": f"service.name = '{service_name}'"},
"functions": [],
"groupBy": [],
"having": {"expression": ""},
"legend": "",
"limit": 10,
"orderBy": [],
"queryName": "A",
"source": "",
"stepInterval": 60,
}
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [{"disabled": False, "legend": "", "name": "A", "query": ""}],
"id": "c3d1b2e5-0a4f-4d3b-9b8c-2f3a4b5c6d7e",
"promql": [{"disabled": False, "legend": "", "name": "A", "query": ""}],
"queryType": "builder",
},
}
],
}
create_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
json=dashboard_req,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert create_response.status_code == HTTPStatus.CREATED
dashboard_id = create_response.json()["data"]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{dashboard_id}/public"),
json={"timeRangeEnabled": False, "defaultTimeRange": "30m"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{dashboard_id}/public"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
public_path = response.json()["data"]["publicPath"]
public_dashboard_id = public_path.split("/public/dashboard/")[-1]
resp = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/public/dashboards/{public_dashboard_id}/widgets/0/query_range"),
timeout=5,
)
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["status"] == "success"
# The single "count(), sum(latency_ms)" expression must have been split into two
# separate aggregations on the way to the querier.
results = body["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0]["aggregations"]
assert len(aggregations) == 2
# With no group-by each aggregation produces a single series.
for aggregation in aggregations:
assert len(aggregation["series"]) == 1
assert len(aggregation["series"][0]["values"]) > 0
# Each aggregation is computed independently over the three logs: count() totals 3,
# sum(latency_ms) totals 100 + 200 + 300 = 600. Summing each aggregation's points is
# robust to step bucketing and to the order the aggregations come back in.
aggregation_totals = sorted(
sum(point["value"] for series in aggregation["series"] for point in series["values"])
for aggregation in aggregations
)
assert aggregation_totals == [3, 600]
def test_anonymous_role_has_public_dashboard_permission(
request: pytest.FixtureRequest,
signoz: SigNoz,