mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-06 00:10:41 +00:00
Compare commits
8 Commits
feat/input
...
fix/query-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38f4b85a78 | ||
|
|
add47f6516 | ||
|
|
2d3c4fd7d2 | ||
|
|
7268d5ebf5 | ||
|
|
9fc012d819 | ||
|
|
e17252420c | ||
|
|
ac153c34f3 | ||
|
|
f234da5983 |
@@ -48,18 +48,19 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
|
||||
import { getOpenResourceTarget } from './utils/getOpenResourceTarget';
|
||||
import {
|
||||
getPanelTypeForRequestType,
|
||||
requestTypeFromActionQuery,
|
||||
} from './utils/getPanelTypeForRequestType';
|
||||
import { isOnResourcePage } from './utils/isOnResourcePage';
|
||||
import { openSavedViewByKey } from './utils/openSavedView';
|
||||
import {
|
||||
isSavedViewOpenAction,
|
||||
resolveOpenResourceType,
|
||||
resolveResourceId,
|
||||
resolveSavedViewSourceHint,
|
||||
} from './utils/resolveOpenResource';
|
||||
import { ResourceType, resourceRoute } from './utils/resourceRoute';
|
||||
import { ResourceType } from './utils/resourceRoute';
|
||||
|
||||
import styles from './ActionsSection.module.scss';
|
||||
|
||||
@@ -397,7 +398,7 @@ export default function ActionsSection({
|
||||
actions,
|
||||
messageId,
|
||||
}: ActionsSectionProps): JSX.Element | null {
|
||||
const { pathname } = useAppLocation();
|
||||
const { pathname, search } = useAppLocation();
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
|
||||
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
|
||||
@@ -493,23 +494,17 @@ export default function ActionsSection({
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceType = resolveOpenResourceType(action);
|
||||
const resourceId = resolveResourceId(action);
|
||||
if (!resourceType || !resourceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = resourceRoute(resourceType, resourceId);
|
||||
if (!path) {
|
||||
const target = getOpenResourceTarget(action);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
void logEvent(AIAssistantEvents.ResourceOpened, {
|
||||
threadId,
|
||||
messageId,
|
||||
targetModule: targetModuleForResource(resourceType),
|
||||
resourceId,
|
||||
targetModule: targetModuleForResource(target.resourceType),
|
||||
resourceId: target.resourceId,
|
||||
});
|
||||
navigate(path);
|
||||
navigate(target.path);
|
||||
};
|
||||
|
||||
const handleClick = (key: string, action: MessageActionDTO): void => {
|
||||
@@ -607,6 +602,10 @@ export default function ActionsSection({
|
||||
// is still surfaced visually via the suffix pill below.
|
||||
|
||||
const tooltip = isError ? result.error : (action.tooltip ?? undefined);
|
||||
const openTarget = getOpenResourceTarget(action);
|
||||
const isViewingResource =
|
||||
openTarget !== null &&
|
||||
isOnResourcePage(openTarget.path, { pathname, search });
|
||||
|
||||
let icon: JSX.Element;
|
||||
if (isSuccess) {
|
||||
@@ -619,12 +618,16 @@ export default function ActionsSection({
|
||||
|
||||
const chip = (
|
||||
<Button
|
||||
disabledTooltip="This action already ran"
|
||||
disabledTooltip={
|
||||
isViewingResource
|
||||
? "You're already viewing this page"
|
||||
: 'This action already ran'
|
||||
}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => handleClick(key, action)}
|
||||
disabled={isSuccess}
|
||||
disabled={isSuccess || isViewingResource}
|
||||
loading={isLoading}
|
||||
aria-label={action.label}
|
||||
testId={`ai-action-chip-${action.kind}`}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
MessageActionDTO,
|
||||
MessageActionKindDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { getCurrentLocation, navigate } from 'lib/router/navigation';
|
||||
import { act, render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { AIAssistantEvents } from '../../../events';
|
||||
import ActionsSection from '../ActionsSection';
|
||||
|
||||
jest.mock('api/common/logEvent');
|
||||
|
||||
const mockLogEvent = jest.mocked(logEvent);
|
||||
|
||||
const OPEN_ALERT: MessageActionDTO = {
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open alert',
|
||||
resourceType: 'alert',
|
||||
resourceId: 'rule-1',
|
||||
actionMetadataId: 'meta-1',
|
||||
};
|
||||
|
||||
const OPEN_ALERT_CHIP = 'ai-action-chip-open_resource';
|
||||
|
||||
describe('ActionsSection open_resource', () => {
|
||||
beforeEach(() => {
|
||||
mockLogEvent.mockClear();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/alerts/edit?ruleId=rule-1&tab=1',
|
||||
'/alerts/overview?ruleId=rule-1&relativeTime=6h',
|
||||
])('disables Open alert while that alert is open at %s', async (route) => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ActionsSection actions={[OPEN_ALERT]} messageId="message-1" />,
|
||||
undefined,
|
||||
{ initialRoute: route },
|
||||
);
|
||||
|
||||
const chip = screen.getByTestId(OPEN_ALERT_CHIP);
|
||||
// With a disabled tooltip the button is aria-disabled so the tooltip stays reachable.
|
||||
expect(chip).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
await user.click(chip);
|
||||
|
||||
expect(`${getCurrentLocation().pathname}${getCurrentLocation().search}`).toBe(
|
||||
route,
|
||||
);
|
||||
expect(mockLogEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['/alerts/edit?ruleId=rule-2', '/alerts/history?ruleId=rule-1'])(
|
||||
'opens the alert from %s',
|
||||
async (route) => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ActionsSection actions={[OPEN_ALERT]} messageId="message-2" />,
|
||||
undefined,
|
||||
{ initialRoute: route },
|
||||
);
|
||||
|
||||
const chip = screen.getByTestId(OPEN_ALERT_CHIP);
|
||||
expect(chip).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
await user.click(chip);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
`${getCurrentLocation().pathname}${getCurrentLocation().search}`,
|
||||
).toBe('/alerts/edit?ruleId=rule-1'),
|
||||
);
|
||||
expect(mockLogEvent).toHaveBeenCalledWith(
|
||||
AIAssistantEvents.ResourceOpened,
|
||||
expect.objectContaining({ resourceId: 'rule-1' }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('enables Open alert again after navigating away from that alert', () => {
|
||||
render(
|
||||
<ActionsSection actions={[OPEN_ALERT]} messageId="message-3" />,
|
||||
undefined,
|
||||
{ initialRoute: '/alerts/edit?ruleId=rule-1' },
|
||||
);
|
||||
expect(screen.getByTestId(OPEN_ALERT_CHIP)).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
|
||||
act(() => navigate('/alerts/edit?ruleId=rule-2'));
|
||||
|
||||
expect(screen.getByTestId(OPEN_ALERT_CHIP)).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
type MessageActionDTO,
|
||||
MessageActionKindDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import {
|
||||
isSavedViewOpenAction,
|
||||
resolveOpenResourceType,
|
||||
resolveResourceId,
|
||||
} from './resolveOpenResource';
|
||||
import { resourceRoute } from './resourceRoute';
|
||||
|
||||
export interface OpenResourceTarget {
|
||||
path: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
// Saved views return null so they're never disabled: reopening one resets the explorer to the saved query.
|
||||
export function getOpenResourceTarget(
|
||||
action: MessageActionDTO,
|
||||
): OpenResourceTarget | null {
|
||||
if (
|
||||
action.kind !== MessageActionKindDTO.open_resource ||
|
||||
isSavedViewOpenAction(action)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const resourceType = resolveOpenResourceType(action);
|
||||
const resourceId = resolveResourceId(action);
|
||||
if (!resourceType || !resourceId) {
|
||||
return null;
|
||||
}
|
||||
const path = resourceRoute(resourceType, resourceId);
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
return { path, resourceType, resourceId };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import type { AppLocation } from 'lib/router/types';
|
||||
|
||||
// The alert details Overview tab renders the same editor as the edit route.
|
||||
const EQUIVALENT_PATHNAMES: Record<string, string[]> = {
|
||||
[ROUTES.EDIT_ALERTS]: [ROUTES.ALERT_OVERVIEW],
|
||||
};
|
||||
|
||||
// Extra params on the current page (time range, view options) don't make it a different resource.
|
||||
export function isOnResourcePage(
|
||||
path: string,
|
||||
location: Pick<AppLocation, 'pathname' | 'search'>,
|
||||
): boolean {
|
||||
const [pathname, query = ''] = path.split('?');
|
||||
const pathnames = [pathname, ...(EQUIVALENT_PATHNAMES[pathname] ?? [])];
|
||||
if (!pathnames.includes(location.pathname)) {
|
||||
return false;
|
||||
}
|
||||
const current = new URLSearchParams(location.search);
|
||||
return [...new URLSearchParams(query)].every(
|
||||
([key, value]) => current.get(key) === value,
|
||||
);
|
||||
}
|
||||
@@ -9,12 +9,13 @@ import (
|
||||
// NewValidReferences / NewSuggestionsOnLevenshteinDistance to phrase the
|
||||
// "valid <noun> are ..." list consistently across the codebase.
|
||||
const (
|
||||
NounFields = "fields"
|
||||
NounKeys = "keys"
|
||||
NounServices = "services"
|
||||
NounQueryTypes = "query types"
|
||||
NounSignals = "signals"
|
||||
NounReferences = "references"
|
||||
NounFields = "fields"
|
||||
NounKeys = "keys"
|
||||
NounServices = "services"
|
||||
NounQueryTypes = "query types"
|
||||
NounRequestTypes = "request types"
|
||||
NounSignals = "signals"
|
||||
NounReferences = "references"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -22,6 +22,10 @@ var (
|
||||
|
||||
ErrCodeDistributedSubqueryDenied = errors.MustNewCode("distributed_subquery_denied")
|
||||
ErrCodeDecimalOverflow = errors.MustNewCode("decimal_overflow")
|
||||
ErrCodeNoCommonType = errors.MustNewCode("no_common_type")
|
||||
ErrCodeNotAnAggregate = errors.MustNewCode("not_an_aggregate")
|
||||
ErrCodeIllegalAggregation = errors.MustNewCode("illegal_aggregation")
|
||||
ErrCodeInvalidRegexp = errors.MustNewCode("invalid_regexp")
|
||||
|
||||
ErrCodeQueryCanceled = errors.MustNewCode("query_canceled")
|
||||
ErrCodeQueryTimeout = errors.MustNewCode("query_timeout")
|
||||
@@ -87,6 +91,26 @@ var clickHouseExceptionWrappers = map[chproto.Error]func(cause error, ex *clickh
|
||||
return errors.WrapInvalidInputf(cause, ErrCodeDecimalOverflow, "decimal overflow: %s", ex.Message).
|
||||
WithSuggestions("cast the Decimal values to Float64, or to a wider Decimal such as Decimal128, before the arithmetic")
|
||||
},
|
||||
chproto.ErrNoCommonType: func(cause error, ex *clickhouse.Exception) error {
|
||||
return errors.WrapInvalidInputf(cause, ErrCodeNoCommonType, "values have no common type: %s", ex.Message).
|
||||
WithSuggestions("cast the values to one type, for example with toInt64 or toFloat64, so that the branches of if or multiIf, the elements of an array, or the columns of a UNION share it; count() returns UInt64, which has no common type with Int64")
|
||||
},
|
||||
chproto.ErrNotAnAggregate: func(cause error, ex *clickhouse.Exception) error {
|
||||
return errors.WrapInvalidInputf(cause, ErrCodeNotAnAggregate, "column is neither in GROUP BY nor inside an aggregate function: %s", ex.Message).
|
||||
WithSuggestions("add the column to GROUP BY, or wrap it in an aggregate function such as any()")
|
||||
},
|
||||
chproto.ErrIllegalAggregation: func(cause error, ex *clickhouse.Exception) error {
|
||||
return errors.WrapInvalidInputf(cause, ErrCodeIllegalAggregation, "aggregate function in a place that does not allow one: %s", ex.Message).
|
||||
WithSuggestions("give an aggregate an alias that is not a column name, because ClickHouse reads the alias in WHERE; move a condition on an aggregate from WHERE to HAVING; compute a nested aggregate in a subquery")
|
||||
},
|
||||
chproto.ErrSizesOfArraysDontMatch: func(cause error, ex *clickhouse.Exception) error {
|
||||
return errors.WrapInvalidInputf(cause, ErrCodeIllegalArgument, "arrays of different sizes: %s", ex.Message).
|
||||
WithSuggestions("pass arrays of the same length to a function that takes several arrays, such as arrayMap or arrayZip")
|
||||
},
|
||||
chproto.ErrCannotCompileRegexp: func(cause error, ex *clickhouse.Exception) error {
|
||||
return errors.WrapInvalidInputf(cause, ErrCodeInvalidRegexp, "regular expression does not compile: %s", ex.Message).
|
||||
WithSuggestions("write the pattern in RE2 syntax, which has no lookaround or backreferences, and escape a backslash that is meant literally")
|
||||
},
|
||||
}
|
||||
|
||||
type provider struct {
|
||||
|
||||
@@ -104,6 +104,10 @@ func (f QueryBuilderFormula) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
if f.Limit < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be non-negative, got %d", f.Limit)
|
||||
}
|
||||
|
||||
// Validate functions if present
|
||||
for i, fn := range f.Functions {
|
||||
if err := fn.Validate(); err != nil {
|
||||
|
||||
@@ -23,7 +23,8 @@ func (r *RequestType) UnmarshalJSON(data []byte) error {
|
||||
*r = v
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q; allowed values: %s", s, "`scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `distribution`, `heatmap`")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q", s).
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -495,6 +495,18 @@ func (q *QueryBuilderQuery[T]) validateOrderBy(cfg validationConfig) error {
|
||||
return q.validateOrderByForAggregation()
|
||||
}
|
||||
|
||||
// a raw order key resolves like a select field, so only an empty one is
|
||||
// invalid before resolution
|
||||
for i, order := range q.Order {
|
||||
if order.Key.Name == "" {
|
||||
orderID := fmt.Sprintf("order by clause #%d", i+1)
|
||||
if q.Name != "" {
|
||||
orderID = fmt.Sprintf("order by clause #%d in query '%s'", i+1, q.Name)
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid empty key name for %s", orderID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -582,19 +594,10 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate request type
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
default:
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid request type: %s",
|
||||
r.RequestType,
|
||||
).WithAdditional(
|
||||
"Valid request types are: raw, timeseries, scalar, heatmap",
|
||||
)
|
||||
if err := r.validateRequestType(); err != nil {
|
||||
return err
|
||||
}
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
|
||||
if err := r.validateHeatmap(); err != nil {
|
||||
return err
|
||||
@@ -635,14 +638,10 @@ func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var opts []ValidationOption
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
|
||||
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, heatmap")
|
||||
if err := r.validateRequestType(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := GetValidationOptions(r.RequestType)
|
||||
|
||||
if err := r.validateHeatmap(); err != nil {
|
||||
return nil, err
|
||||
@@ -699,6 +698,20 @@ func (e QueryEnvelope) Validate(opts ...ValidationOption) error {
|
||||
return validateQueryEnvelope(e, opts...)
|
||||
}
|
||||
|
||||
// Enum is the one list of request types a query can use, so the spec and the
|
||||
// suggestions agree with what is accepted.
|
||||
func (r *QueryRangeRequest) validateRequestType() error {
|
||||
if slices.Contains(RequestType{}.Enum(), any(r.RequestType)) {
|
||||
return nil
|
||||
}
|
||||
if r.RequestType == RequestTypeUnknown {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "requestType is required").
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request type %q is not supported", r.RequestType.StringValue()).
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
}
|
||||
|
||||
func (r *QueryRangeRequest) validateTimeRange() error {
|
||||
if r.RequestType == RequestTypeRawStream {
|
||||
return nil
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
@@ -521,6 +526,28 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
wantErr: true,
|
||||
errMsg: "expression cannot be blank",
|
||||
},
|
||||
{
|
||||
name: "Formula_NegativeLimit_Rejected",
|
||||
request: QueryRangeRequest{
|
||||
Start: 1640995200000,
|
||||
End: 1640998800000,
|
||||
RequestType: RequestTypeTimeSeries,
|
||||
CompositeQuery: CompositeQuery{
|
||||
Queries: []QueryEnvelope{
|
||||
{
|
||||
Type: QueryTypeFormula,
|
||||
Spec: QueryBuilderFormula{
|
||||
Name: "F1",
|
||||
Expression: "A * 2",
|
||||
Limit: -5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid formula 'F1': limit must be non-negative, got -5",
|
||||
},
|
||||
{
|
||||
name: "promql with empty query should return error",
|
||||
request: QueryRangeRequest{
|
||||
@@ -696,6 +723,31 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
wantErr: true,
|
||||
errMsg: "raw request type is not supported for metric queries",
|
||||
},
|
||||
{
|
||||
name: "Raw_EmptyOrderKey_Rejected",
|
||||
request: QueryRangeRequest{
|
||||
Start: 1640995200000,
|
||||
End: 1640998800000,
|
||||
RequestType: RequestTypeRaw,
|
||||
CompositeQuery: CompositeQuery{
|
||||
Queries: []QueryEnvelope{
|
||||
{
|
||||
Type: QueryTypeBuilder,
|
||||
Spec: QueryBuilderQuery[LogAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Order: []OrderBy{
|
||||
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "timestamp"}}, Direction: OrderDirectionDesc},
|
||||
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: ""}}, Direction: OrderDirectionAsc},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid query 'B': invalid empty key name for order by clause #2 in query 'B'",
|
||||
},
|
||||
{
|
||||
name: "timeseries request with group by timestamp should return error",
|
||||
request: QueryRangeRequest{
|
||||
@@ -1407,6 +1459,44 @@ func TestRequestType_IsAggregation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRangeRequest_ValidateRequestType(t *testing.T) {
|
||||
validRequestTypes := []string{"valid request types are `scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `heatmap`"}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestType string
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "Missing_Required",
|
||||
requestType: "",
|
||||
message: "requestType is required",
|
||||
},
|
||||
{
|
||||
name: "Timeseries_Unknown",
|
||||
requestType: `"requestType": "timeseries",`,
|
||||
message: `unknown request type "timeseries"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
body := fmt.Sprintf(`{"schemaVersion": "v1", "start": 1640995200000, "end": 1640998800000, %s "compositeQuery": {"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs"}}]}}`, testCase.requestType)
|
||||
|
||||
var request QueryRangeRequest
|
||||
err := json.Unmarshal([]byte(body), &request)
|
||||
if err == nil {
|
||||
err = request.Validate()
|
||||
}
|
||||
require.Error(t, err)
|
||||
|
||||
got := errors.AsJSON(err)
|
||||
assert.Equal(t, testCase.message, got.Message)
|
||||
assert.Equal(t, validRequestTypes, got.Suggestions)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonAggregationFieldsSkipped(t *testing.T) {
|
||||
// Fields that only apply to aggregation queries (groupBy, having, aggregations)
|
||||
// should be silently skipped for non-aggregation request types.
|
||||
|
||||
@@ -2,8 +2,11 @@ from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import querier, types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
|
||||
|
||||
def test_clickhouse_scalar_numeric_result_alias_classified_as_aggregation(
|
||||
@@ -68,3 +71,83 @@ def test_clickhouse_scalar_numeric_result_alias_classified_as_aggregation(
|
||||
assert columns[0]["name"] == "value"
|
||||
assert columns[0]["columnType"] == "aggregation"
|
||||
assert columns[0]["aggregationIndex"] == 0
|
||||
|
||||
|
||||
NO_COMMON_TYPE = "cast the values to one type, for example with toInt64 or toFloat64, so that the branches of if or multiIf, the elements of an array, or the columns of a UNION share it; count() returns UInt64, which has no common type with Int64"
|
||||
ILLEGAL_AGGREGATION = "give an aggregate an alias that is not a column name, because ClickHouse reads the alias in WHERE; move a condition on an aggregate from WHERE to HAVING; compute a nested aggregate in a subquery"
|
||||
INVALID_REGEXP = "write the pattern in RE2 syntax, which has no lookaround or backreferences, and escape a backslash that is meant literally"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,code,suggestion",
|
||||
[
|
||||
pytest.param(
|
||||
{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT if(number > 5, toInt64(-1), count()) AS value FROM numbers(10) GROUP BY number"}},
|
||||
"no_common_type",
|
||||
NO_COMMON_TYPE,
|
||||
id="no_common_type",
|
||||
),
|
||||
pytest.param(
|
||||
{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT number, count() AS value FROM numbers(10) GROUP BY number % 2"}},
|
||||
"not_an_aggregate",
|
||||
"add the column to GROUP BY, or wrap it in an aggregate function such as any()",
|
||||
id="not_an_aggregate",
|
||||
),
|
||||
pytest.param(
|
||||
{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT any(number) AS number FROM numbers(10) WHERE number > 5"}},
|
||||
"illegal_aggregation",
|
||||
ILLEGAL_AGGREGATION,
|
||||
id="aggregate_alias_in_where",
|
||||
),
|
||||
pytest.param(
|
||||
{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT sum(count()) AS value FROM numbers(10)"}},
|
||||
"illegal_aggregation",
|
||||
ILLEGAL_AGGREGATION,
|
||||
id="nested_aggregate",
|
||||
),
|
||||
pytest.param(
|
||||
{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT arrayMap((x, y) -> x + y, [1, 2], [1]) AS value"}},
|
||||
"illegal_argument",
|
||||
"pass arrays of the same length to a function that takes several arrays, such as arrayMap or arrayZip",
|
||||
id="arrays_of_different_sizes",
|
||||
),
|
||||
pytest.param(
|
||||
{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT match('abc', '(?=a)') AS value"}},
|
||||
"invalid_regexp",
|
||||
INVALID_REGEXP,
|
||||
id="regexp_in_sql",
|
||||
),
|
||||
pytest.param(
|
||||
querier.build_scalar_query("A", "logs", [querier.build_aggregation("count()")], filter_expression="body REGEXP '(?=a)'"),
|
||||
"invalid_regexp",
|
||||
INVALID_REGEXP,
|
||||
id="regexp_in_filter",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_clickhouse_user_mistake_is_bad_request(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
query: dict,
|
||||
code: str,
|
||||
suggestion: str,
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
# ClickHouse compiles a filter's regular expression only when it reads a part.
|
||||
insert_logs([Logs(timestamp=now - timedelta(minutes=1), body="abc")])
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = querier.make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
int((now - timedelta(hours=1)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[query],
|
||||
request_type=querier.RequestType.SCALAR,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
error = response.json()["error"]
|
||||
assert (error["code"], error["suggestions"]) == (code, [suggestion]), error
|
||||
|
||||
Reference in New Issue
Block a user