Compare commits

...

8 Commits

Author SHA1 Message Date
ahmadshaheer
6f772b187e chore: add tests for delete button visibility 2025-12-16 18:23:14 +04:30
Srikanth Chekuri
40bbbb9d37 Merge branch 'main' into refactor/query-builder-delete-logic 2025-12-04 14:08:58 +05:30
Nityananda Gohain
2f3baeb302 fix: fix third party api filtering (#9770) 2025-12-03 23:36:05 +05:30
Abhishek Kumar Singh
3d42b0058e chore: Query filter extraction API (#9617) 2025-12-03 13:13:32 +00:00
Srikanth Chekuri
ed70e3c5f5 chore: update .github/CODEOWNERS (#9759) 2025-12-03 11:06:30 +00:00
Ishan
7d6918f8b6 style: updated subdomain already exists UI error on 409 (#9661)
* style: updated subdomain already exists UI error on 409

* style: updated subdomain default message

---------

Co-authored-by: Ishan Uniyal <ishan@Ishans-MacBook-Pro.local>
2025-12-03 15:12:44 +05:30
Shaheer Kochai
09973b08b7 Merge branch 'main' into refactor/query-builder-delete-logic 2025-12-03 09:55:52 +04:30
ahmadshaheer
4db785984f refactor: update QueryBuilderV2 and QueryV2 components to use canDelete prop instead of queriesCount 2025-11-23 18:31:19 +04:30
23 changed files with 585 additions and 80 deletions

40
.github/CODEOWNERS vendored
View File

@@ -3,51 +3,11 @@
# that they own.
/frontend/ @YounixM @aks07
/frontend/src/container/MetricsApplication @srikanthccv
/frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv
# Onboarding
/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.json @makeavish
/frontend/src/container/OnboardingV2Container/AddDataSource/AddDataSource.tsx @makeavish
# Dashboard, Alert, Metrics, Service Map, Services
/frontend/src/container/ListOfDashboard/ @srikanthccv
/frontend/src/container/NewDashboard/ @srikanthccv
/frontend/src/pages/DashboardsListPage/ @srikanthccv
/frontend/src/pages/DashboardWidget/ @srikanthccv
/frontend/src/pages/NewDashboard/ @srikanthccv
/frontend/src/providers/Dashboard/ @srikanthccv
# Alerts
/frontend/src/container/AlertHistory/ @srikanthccv
/frontend/src/container/AllAlertChannels/ @srikanthccv
/frontend/src/container/AnomalyAlertEvaluationView/ @srikanthccv
/frontend/src/container/CreateAlertChannels/ @srikanthccv
/frontend/src/container/CreateAlertRule/ @srikanthccv
/frontend/src/container/EditAlertChannels/ @srikanthccv
/frontend/src/container/FormAlertChannels/ @srikanthccv
/frontend/src/container/FormAlertRules/ @srikanthccv
/frontend/src/container/ListAlertRules/ @srikanthccv
/frontend/src/container/TriggeredAlerts/ @srikanthccv
/frontend/src/pages/AlertChannelCreate/ @srikanthccv
/frontend/src/pages/AlertDetails/ @srikanthccv
/frontend/src/pages/AlertHistory/ @srikanthccv
/frontend/src/pages/AlertList/ @srikanthccv
/frontend/src/pages/CreateAlert/ @srikanthccv
/frontend/src/providers/Alert.tsx @srikanthccv
# Metrics
/frontend/src/container/MetricsExplorer/ @srikanthccv
/frontend/src/pages/MetricsApplication/ @srikanthccv
/frontend/src/pages/MetricsExplorer/ @srikanthccv
# Services and Service Map
/frontend/src/container/ServiceApplication/ @srikanthccv
/frontend/src/container/ServiceTable/ @srikanthccv
/frontend/src/pages/Services/ @srikanthccv
/frontend/src/pages/ServiceTopLevelOperations/ @srikanthccv
/frontend/src/container/Home/Services/ @srikanthccv
/deploy/ @SigNoz/devops
.github @SigNoz/devops

View File

@@ -19,6 +19,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -60,6 +61,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
FieldsAPI: fields.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.TelemetryStore),
Signoz: signoz,
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
})
if err != nil {

View File

@@ -179,7 +179,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
isListViewPanel={isListViewPanel}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
queriesCount={1}
canDelete={false}
/>
) : (
currentQuery.builder.queryData.map((query, index) => (
@@ -201,7 +201,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
signalSource={query.source as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
queriesCount={currentQuery.builder.queryData.length}
canDelete={currentQuery.builder.queryData.length > 1}
/>
))
)}

View File

@@ -41,11 +41,11 @@ export const QueryV2 = forwardRef(function QueryV2(
isMultiQueryAllowed = false,
onSignalSourceChange,
signalSourceChangeEnabled = false,
queriesCount = 1,
canDelete = false,
}: QueryProps & {
onSignalSourceChange: (value: string) => void;
signalSourceChangeEnabled: boolean;
queriesCount: number;
canDelete: boolean;
},
ref: ForwardedRef<HTMLDivElement>,
): JSX.Element {
@@ -201,7 +201,7 @@ export const QueryV2 = forwardRef(function QueryV2(
icon: <Copy size={14} />,
onClick: handleCloneEntity,
},
...(queriesCount && queriesCount > 1
...(canDelete
? [
{
label: 'Delete',

View File

@@ -0,0 +1,126 @@
import {
initialQueriesMap,
initialQueryBuilderFormValues,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { QueryBuilderV2Provider } from '../../QueryBuilderV2Context';
import { QueryV2 } from '../QueryV2';
jest.mock('hooks/queryBuilder/useQueryBuilder');
jest.mock('hooks/queryBuilder/useQueryBuilderOperations');
jest.mock('container/QueryBuilder/components/QBEntityOptions/QBEntityOptions');
jest.mock('../QuerySearch/QuerySearch');
jest.mock('../MetricsSelect/MetricsSelect');
jest.mock('../MerticsAggregateSection/MetricsAggregateSection');
jest.mock('../QueryAggregation/QueryAggregation');
jest.mock('../QueryAddOns/QueryAddOns');
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearchV2/SpanScopeSelector',
);
const mockUseQueryBuilder = jest.mocked(useQueryBuilder);
const mockUseQueryOperations = jest.mocked(useQueryOperations);
describe('QueryV2', () => {
const mockHandleDeleteQuery = jest.fn();
const mockHandleChangeQueryData = jest.fn();
const mockHandleQueryFunctionsUpdates = jest.fn();
const mockHandleChangeDataSource = jest.fn();
const mockOnSignalSourceChange = jest.fn();
beforeEach(() => {
mockUseQueryBuilder.mockReturnValue({
cloneQuery: jest.fn(),
panelType: PANEL_TYPES.TIME_SERIES,
updateAllQueriesOperators: jest.fn(() => initialQueriesMap.metrics),
} as any);
mockUseQueryOperations.mockReturnValue({
handleDeleteQuery: mockHandleDeleteQuery,
handleChangeQueryData: mockHandleChangeQueryData,
handleQueryFunctionsUpdates: mockHandleQueryFunctionsUpdates,
handleChangeDataSource: mockHandleChangeDataSource,
operators: [],
spaceAggregationOptions: [],
} as any);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should show delete button when canDelete is true', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { container } = render(
<QueryBuilderV2Provider>
<QueryV2
isAvailableToDisable={false}
index={0}
query={initialQueryBuilderFormValues}
isMultiQueryAllowed
canDelete
version="v5"
onSignalSourceChange={mockOnSignalSourceChange}
signalSourceChangeEnabled={false}
filterConfigs={{}}
/>
</QueryBuilderV2Provider>,
);
const dropdown = container.querySelector('.query-actions-dropdown');
expect(dropdown).toBeInTheDocument();
const trigger = dropdown?.firstElementChild as HTMLElement;
expect(trigger).toBeTruthy();
await user.click(trigger);
await waitFor(() => {
expect(screen.getByText('Delete')).toBeInTheDocument();
});
});
it('should hide delete button when canDelete is false', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { container } = render(
<QueryBuilderV2Provider>
<QueryV2
isAvailableToDisable={false}
index={0}
query={initialQueryBuilderFormValues}
isMultiQueryAllowed
canDelete={false}
version="v5"
onSignalSourceChange={mockOnSignalSourceChange}
signalSourceChangeEnabled={false}
filterConfigs={{}}
/>
</QueryBuilderV2Provider>,
);
const dropdown = container.querySelector('.query-actions-dropdown');
expect(dropdown).toBeInTheDocument();
const trigger = dropdown?.firstElementChild as HTMLElement;
expect(trigger).toBeTruthy();
await user.click(trigger);
await waitFor(() => {
expect(screen.getByText('Clone')).toBeInTheDocument();
});
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
});
});

View File

@@ -266,7 +266,10 @@ export default function CustomDomainSettings(): JSX.Element {
<div className="custom-domain-settings-modal-error">
{updateDomainError.status === 409 ? (
<Alert
message="Youve already updated the custom domain once today. To make further changes, please contact our support team for assistance."
message={
(updateDomainError?.response?.data as { error?: string })?.error ||
'Youve already updated the custom domain once today. To make further changes, please contact our support team for assistance.'
}
type="warning"
className="update-limit-reached-error"
/>

View File

@@ -2,11 +2,11 @@ package thirdpartyapi
import (
"fmt"
"github.com/SigNoz/signoz/pkg/types/thirdpartyapitypes"
"net"
"regexp"
"time"
"github.com/SigNoz/signoz/pkg/types/thirdpartyapitypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -287,11 +287,6 @@ func shouldIncludeRow(row *qbtypes.RawRow) bool {
return true
}
func containsKindStringOverride(expression string) bool {
kindStringPattern := regexp.MustCompile(`kind_string\s*[!=<>]+`)
return kindStringPattern.MatchString(expression)
}
func mergeGroupBy(base, additional []qbtypes.GroupByKey) []qbtypes.GroupByKey {
return append(base, additional...)
}
@@ -400,6 +395,8 @@ func buildRpsQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEn
}
func buildErrorQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
filter := buildBaseFilter(req.Filter)
filter.Expression = fmt.Sprintf("has_error = true AND (%s)", filter.Expression)
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -409,7 +406,7 @@ func buildErrorQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.Query
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count()"},
},
Filter: buildErrorFilter(req.Filter),
Filter: filter,
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
},
}
@@ -526,25 +523,9 @@ func buildBaseFilter(additionalFilter *qbtypes.Filter) *qbtypes.Filter {
urlPathKeyLegacy, urlPathKey)
if additionalFilter != nil && additionalFilter.Expression != "" {
if containsKindStringOverride(additionalFilter.Expression) {
return &qbtypes.Filter{Expression: baseExpression}
}
// even if it contains kind_string we add with an AND so it doesn't matter if the user is overriding it.
baseExpression = fmt.Sprintf("(%s) AND (%s)", baseExpression, additionalFilter.Expression)
}
return &qbtypes.Filter{Expression: baseExpression}
}
func buildErrorFilter(additionalFilter *qbtypes.Filter) *qbtypes.Filter {
errorExpression := fmt.Sprintf("has_error = true AND (%s EXISTS OR %s EXISTS) AND kind_string = 'Client'",
urlPathKeyLegacy, urlPathKey)
if additionalFilter != nil && additionalFilter.Expression != "" {
if containsKindStringOverride(additionalFilter.Expression) {
return &qbtypes.Filter{Expression: errorExpression}
}
errorExpression = fmt.Sprintf("(%s) AND (%s)", errorExpression, additionalFilter.Expression)
}
return &qbtypes.Filter{Expression: errorExpression}
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/thirdpartyapi"
"github.com/SigNoz/signoz/pkg/queryparser"
"io"
"math"
@@ -146,6 +147,8 @@ type APIHandler struct {
QuerierAPI *querierAPI.API
QueryParserAPI *queryparser.API
Signoz *signoz.SigNoz
}
@@ -176,6 +179,8 @@ type APIHandlerOpts struct {
QuerierAPI *querierAPI.API
QueryParserAPI *queryparser.API
Signoz *signoz.SigNoz
}
@@ -238,6 +243,7 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) {
Signoz: opts.Signoz,
FieldsAPI: opts.FieldsAPI,
QuerierAPI: opts.QuerierAPI,
QueryParserAPI: opts.QueryParserAPI,
}
logsQueryBuilder := logsv4.PrepareLogsQuery
@@ -632,6 +638,8 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/span_percentile", am.ViewAccess(aH.Signoz.Handlers.SpanPercentile.GetSpanPercentileDetails)).Methods(http.MethodPost)
// Query Filter Analyzer api used to extract metric names and grouping columns from a query
router.HandleFunc("/api/v1/query_filter/analyze", am.ViewAccess(aH.QueryParserAPI.AnalyzeQueryFilter)).Methods(http.MethodPost)
}
func (ah *APIHandler) MetricExplorerRoutes(router *mux.Router, am *middleware.AuthZ) {

View File

@@ -10,6 +10,7 @@ import (
"slices"
"github.com/SigNoz/signoz/pkg/cache/memorycache"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/gorilla/handlers"
@@ -132,6 +133,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
FieldsAPI: fields.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.TelemetryStore),
Signoz: signoz,
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
})
if err != nil {
return nil, err

49
pkg/queryparser/api.go Normal file
View File

@@ -0,0 +1,49 @@
package queryparser
import (
"net/http"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/parsertypes"
)
type API struct {
queryParser QueryParser
settings factory.ProviderSettings
}
func NewAPI(settings factory.ProviderSettings, queryParser QueryParser) *API {
return &API{settings: settings, queryParser: queryParser}
}
// AnalyzeQueryFilter analyzes a query and extracts metric names and grouping columns
func (a *API) AnalyzeQueryFilter(w http.ResponseWriter, r *http.Request) {
// Limit request body size to 255 KB (CH query limit is 256 KB)
r.Body = http.MaxBytesReader(w, r.Body, 255*1024)
var req parsertypes.QueryFilterAnalyzeRequest
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(w, err)
return
}
result, err := a.queryParser.AnalyzeQueryFilter(r.Context(), req.QueryType, req.Query)
if err != nil {
a.settings.Logger.ErrorContext(r.Context(), "failed to analyze query filter", "error", err)
render.Error(w, err)
return
}
// prepare the response
var resp parsertypes.QueryFilterAnalyzeResponse
for _, group := range result.GroupByColumns {
resp.Groups = append(resp.Groups, parsertypes.ColumnInfoResponse{
Name: group.Name,
Alias: group.Alias,
}) // add the group name and alias to the response
}
resp.MetricNames = append(resp.MetricNames, result.MetricNames...) // add the metric names to the response
render.Success(w, http.StatusOK, resp)
}

258
pkg/queryparser/api_test.go Normal file
View File

@@ -0,0 +1,258 @@
package queryparser
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"sort"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/types/parsertypes"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
func TestAPI_AnalyzeQueryFilter(t *testing.T) {
queryParser := New(instrumentationtest.New().ToProviderSettings())
aH := NewAPI(instrumentationtest.New().ToProviderSettings(), queryParser)
tests := []struct {
name string
requestBody parsertypes.QueryFilterAnalyzeRequest
expectedStatus int
expectedStatusStr string
expectedError bool
errorContains string
expectedMetrics []string
expectedGroups []parsertypes.ColumnInfoResponse
}{
{
name: "PromQL - Nested aggregation inside subquery",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `max_over_time(sum(rate(cpu_usage_total[5m]))[1h:5m])`,
QueryType: querybuildertypesv5.QueryTypePromQL,
},
expectedStatus: http.StatusOK,
expectedStatusStr: "success",
expectedError: false,
expectedMetrics: []string{"cpu_usage_total"},
expectedGroups: []parsertypes.ColumnInfoResponse{},
},
{
name: "PromQL - Subquery with multiple metrics",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `avg_over_time((foo + bar)[10m:1m])`,
QueryType: querybuildertypesv5.QueryTypePromQL,
},
expectedStatus: http.StatusOK,
expectedStatusStr: "success",
expectedError: false,
expectedMetrics: []string{"bar", "foo"},
expectedGroups: []parsertypes.ColumnInfoResponse{},
},
{
name: "PromQL - Simple meta-metric with grouping",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `sum by (pod) (up)`,
QueryType: querybuildertypesv5.QueryTypePromQL,
},
expectedStatus: http.StatusOK,
expectedStatusStr: "success",
expectedError: false,
expectedMetrics: []string{"up"},
expectedGroups: []parsertypes.ColumnInfoResponse{{Name: "pod", Alias: ""}},
},
{
name: "ClickHouse - Simple CTE with GROUP BY",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `WITH aggregated AS (
SELECT region as region_alias, sum(value) AS total
FROM metrics
WHERE metric_name = 'cpu_usage'
GROUP BY region
)
SELECT * FROM aggregated`,
QueryType: querybuildertypesv5.QueryTypeClickHouseSQL,
},
expectedStatus: http.StatusOK,
expectedStatusStr: "success",
expectedError: false,
expectedMetrics: []string{"cpu_usage"},
expectedGroups: []parsertypes.ColumnInfoResponse{{Name: "region", Alias: "region_alias"}},
},
{
name: "ClickHouse - CTE chain with last GROUP BY + Alias should be returned if exists",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `WITH step1 AS (
SELECT service as service_alias, timestamp as ts, value
FROM metrics
WHERE metric_name = 'requests'
GROUP BY service, timestamp
),
step2 AS (
SELECT ts, avg(value) AS avg_value
FROM step1
GROUP BY ts
)
SELECT * FROM step2`,
QueryType: querybuildertypesv5.QueryTypeClickHouseSQL,
},
expectedStatus: http.StatusOK,
expectedStatusStr: "success",
expectedError: false,
expectedMetrics: []string{"requests"},
expectedGroups: []parsertypes.ColumnInfoResponse{{Name: "ts", Alias: ""}},
},
{
name: "ClickHouse - Outer GROUP BY overrides CTE GROUP BY + Alias should be returned if exists",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `WITH cte AS (
SELECT region, service, value
FROM metrics
WHERE metric_name = 'memory'
GROUP BY region, service
)
SELECT region as region_alias, sum(value) as total
FROM cte
GROUP BY region`,
QueryType: querybuildertypesv5.QueryTypeClickHouseSQL,
},
expectedStatus: http.StatusOK,
expectedStatusStr: "success",
expectedError: false,
expectedMetrics: []string{"memory"},
expectedGroups: []parsertypes.ColumnInfoResponse{{Name: "region", Alias: "region_alias"}},
},
{
name: "ClickHouse - Invalid query should return error",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `SELECT WHERE metric_name = 'memory' GROUP BY region, service`,
QueryType: querybuildertypesv5.QueryTypeClickHouseSQL,
},
expectedStatus: http.StatusBadRequest,
expectedStatusStr: "error",
expectedError: true,
errorContains: "failed to parse clickhouse query",
},
{
name: "Empty query should return error",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: "",
QueryType: querybuildertypesv5.QueryTypePromQL,
},
expectedStatus: http.StatusBadRequest,
expectedStatusStr: "error",
expectedError: true,
errorContains: "query is required and cannot be empty",
},
{
name: "Invalid queryType should return error",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `sum(rate(cpu_usage[5m]))`,
QueryType: querybuildertypesv5.QueryTypeUnknown,
},
expectedStatus: http.StatusBadRequest,
expectedStatusStr: "error",
expectedError: true,
errorContains: "unsupported queryType",
},
{
name: "Invalid PromQL syntax should return error",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `sum by ((foo)(bar))(http_requests_total)`,
QueryType: querybuildertypesv5.QueryTypePromQL,
},
expectedStatus: http.StatusBadRequest,
expectedStatusStr: "error",
expectedError: true,
errorContains: "failed to parse promql query",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create request body
reqBody, err := json.Marshal(tt.requestBody)
if err != nil {
t.Fatalf("failed to marshal request body: %v", err)
}
// Create HTTP request
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/v1/query_filter/analyze", bytes.NewBuffer(reqBody))
req.Header.Set("Content-Type", "application/json")
// Create response recorder
rr := httptest.NewRecorder()
// Call handler
aH.AnalyzeQueryFilter(rr, req)
// Check status code
if rr.Code != tt.expectedStatus {
t.Errorf("expected status %d, got %d", tt.expectedStatus, rr.Code)
}
// Parse response
var resp map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v, body: %s", err, rr.Body.String())
}
// Check status string
if resp["status"] != tt.expectedStatusStr {
t.Errorf("expected status '%s', got %v", tt.expectedStatusStr, resp["status"])
}
if tt.expectedError {
errorObj, ok := resp["error"].(map[string]interface{})
if !ok {
t.Fatalf("expected error to be a map, got %T", resp["error"])
}
errorMsg, ok := errorObj["message"].(string)
if !ok {
t.Fatalf("expected error message to be a string, got %T", errorObj["message"])
}
if !strings.Contains(errorMsg, tt.errorContains) {
t.Errorf("expected error message to contain '%s', got '%s'", tt.errorContains, errorMsg)
}
} else {
// Validate success response
data, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected data to be a map, got %T", resp["data"])
}
// Marshal data back to JSON and unmarshal into QueryFilterAnalyzeResponse struct
dataBytes, err := json.Marshal(data)
if err != nil {
t.Fatalf("failed to marshal data: %v", err)
}
var responseData parsertypes.QueryFilterAnalyzeResponse
if err := json.Unmarshal(dataBytes, &responseData); err != nil {
t.Fatalf("failed to unmarshal data into QueryFilterAnalyzeResponse: %v", err)
}
// Sort the arrays for comparison
gotMetrics := make([]string, len(responseData.MetricNames))
copy(gotMetrics, responseData.MetricNames)
sort.Strings(gotMetrics)
gotGroups := make([]parsertypes.ColumnInfoResponse, len(responseData.Groups))
copy(gotGroups, responseData.Groups)
// Compare using deep equal
if !reflect.DeepEqual(gotMetrics, tt.expectedMetrics) {
t.Errorf("expected metricNames %v, got %v", tt.expectedMetrics, gotMetrics)
}
if !reflect.DeepEqual(gotGroups, tt.expectedGroups) {
t.Errorf("expected groups %v, got %v", tt.expectedGroups, gotGroups)
}
}
})
}
}

View File

@@ -4,11 +4,18 @@
// This is useful for metrics discovery, and query analysis.
package queryfilterextractor
import "github.com/SigNoz/signoz/pkg/errors"
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
ExtractorCH = "qfe_ch"
ExtractorPromQL = "qfe_promql"
type ExtractorType struct {
valuer.String
}
var (
ExtractorTypeClickHouseSQL = ExtractorType{valuer.NewString("qfe_ch")}
ExtractorTypePromQL = ExtractorType{valuer.NewString("qfe_promql")}
)
// ColumnInfo represents a column in the query
@@ -46,13 +53,13 @@ type FilterExtractor interface {
Extract(query string) (*FilterResult, error)
}
func NewExtractor(extractorType string) (FilterExtractor, error) {
func NewExtractor(extractorType ExtractorType) (FilterExtractor, error) {
switch extractorType {
case ExtractorCH:
case ExtractorTypeClickHouseSQL:
return NewClickHouseFilterExtractor(), nil
case ExtractorPromQL:
case ExtractorTypePromQL:
return NewPromQLFilterExtractor(), nil
default:
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid extractor type: %s", extractorType)
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid extractor type: %s", extractorType)
}
}

View File

@@ -0,0 +1,14 @@
package queryparser
import (
"context"
"github.com/SigNoz/signoz/pkg/queryparser/queryfilterextractor"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
// QueryParser defines the interface for parsing and analyzing queries.
type QueryParser interface {
// AnalyzeQueryFilter extracts filter conditions from a given query string.
AnalyzeQueryFilter(ctx context.Context, queryType querybuildertypesv5.QueryType, query string) (*queryfilterextractor.FilterResult, error)
}

View File

@@ -0,0 +1,40 @@
package queryparser
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/queryparser/queryfilterextractor"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type queryParserImpl struct {
settings factory.ProviderSettings
}
// New creates a new implementation of the QueryParser service.
func New(settings factory.ProviderSettings) QueryParser {
return &queryParserImpl{
settings: settings,
}
}
func (p *queryParserImpl) AnalyzeQueryFilter(ctx context.Context, queryType querybuildertypesv5.QueryType, query string) (*queryfilterextractor.FilterResult, error) {
var extractorType queryfilterextractor.ExtractorType
switch queryType {
case querybuildertypesv5.QueryTypePromQL:
extractorType = queryfilterextractor.ExtractorTypePromQL
case querybuildertypesv5.QueryTypeClickHouseSQL:
extractorType = queryfilterextractor.ExtractorTypeClickHouseSQL
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported queryType: %s. Supported values are '%s' and '%s'", queryType, querybuildertypesv5.QueryTypePromQL, querybuildertypesv5.QueryTypeClickHouseSQL)
}
// Create extractor
extractor, err := queryfilterextractor.NewExtractor(extractorType)
if err != nil {
return nil, err
}
return extractor.Extract(query)
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/sqlmigration"
"github.com/SigNoz/signoz/pkg/sqlmigrator"
@@ -62,6 +63,7 @@ type SigNoz struct {
Authz authz.AuthZ
Modules Modules
Handlers Handlers
QueryParser queryparser.QueryParser
}
func New(
@@ -309,6 +311,9 @@ func New(
return nil, err
}
// Initialize query parser
queryParser := queryparser.New(providerSettings)
// Initialize authns
store := sqlauthnstore.NewStore(sqlstore)
authNs, err := authNsCallback(ctx, providerSettings, store, licensing)
@@ -402,5 +407,6 @@ func New(
Authz: authz,
Modules: modules,
Handlers: handlers,
QueryParser: queryParser,
}, nil
}

View File

@@ -0,0 +1,49 @@
package parsertypes
import (
"encoding/json"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
// QueryFilterAnalyzeRequest represents the request body for query filter analysis
type QueryFilterAnalyzeRequest struct {
Query string `json:"query"`
QueryType querybuildertypesv5.QueryType `json:"queryType"`
}
// UnmarshalJSON implements custom JSON unmarshaling with validation and normalization
func (q *QueryFilterAnalyzeRequest) UnmarshalJSON(data []byte) error {
// Use a temporary struct to avoid infinite recursion
type Alias QueryFilterAnalyzeRequest
aux := (*Alias)(q)
if err := json.Unmarshal(data, aux); err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to parse json: %v", err)
}
// Trim and validate query is not empty
q.Query = strings.TrimSpace(aux.Query)
if q.Query == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "query is required and cannot be empty")
}
// Validate query type
if aux.QueryType != querybuildertypesv5.QueryTypeClickHouseSQL && aux.QueryType != querybuildertypesv5.QueryTypePromQL {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported queryType: %v. Supported values are '%s' and '%s'", aux.QueryType, querybuildertypesv5.QueryTypePromQL, querybuildertypesv5.QueryTypeClickHouseSQL)
}
return nil
}
type ColumnInfoResponse struct {
Name string `json:"columnName"`
Alias string `json:"columnAlias"`
}
// QueryFilterAnalyzeResponse represents the response body for query filter analysis
type QueryFilterAnalyzeResponse struct {
MetricNames []string `json:"metricNames"`
Groups []ColumnInfoResponse `json:"groups"`
}

View File

@@ -11,7 +11,7 @@ type ThirdPartyApiRequest struct {
ShowIp bool `json:"show_ip,omitempty"`
Domain string `json:"domain,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Filter *qbtypes.Filter `json:"filters,omitempty"`
Filter *qbtypes.Filter `json:"filter,omitempty"`
GroupBy []qbtypes.GroupByKey `json:"groupBy,omitempty"`
}