mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-11 19:00:40 +00:00
Compare commits
19 Commits
tvats-fix-
...
srikanth/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd15b85dde | ||
|
|
f02b5521c6 | ||
|
|
4b1b3092d5 | ||
|
|
d6017f88fd | ||
|
|
9d28014664 | ||
|
|
1997901102 | ||
|
|
84268d00c8 | ||
|
|
628e989bc0 | ||
|
|
74d272a945 | ||
|
|
f8421e81e9 | ||
|
|
e938a10ad5 | ||
|
|
93cc1957d4 | ||
|
|
313ec2d657 | ||
|
|
87bcd1b702 | ||
|
|
43cde62108 | ||
|
|
94367f4575 | ||
|
|
108b2d8694 | ||
|
|
fa6fda338f | ||
|
|
d1f4503b40 |
9
.github/CODEOWNERS
vendored
9
.github/CODEOWNERS
vendored
@@ -209,6 +209,10 @@ go.mod @therealpandey
|
||||
/frontend/src/types/api/alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/providers/Alert.tsx @SigNoz/pulse-frontend
|
||||
/frontend/src/constants/alerts.ts @SigNoz/pulse-frontend
|
||||
/frontend/src/container/PlannedDowntime/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/Home/AlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/Home/__tests__/AlertRules.test.tsx @SigNoz/pulse-frontend
|
||||
|
||||
## Notification Channels
|
||||
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
|
||||
@@ -217,6 +221,11 @@ go.mod @therealpandey
|
||||
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
|
||||
|
||||
## List filter + views (dashboards and alerts lists)
|
||||
/frontend/src/components/ListFilter/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/ListViews/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/listViews/ @SigNoz/pulse-frontend
|
||||
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
2
.github/workflows/build-community.yaml
vendored
2
.github/workflows/build-community.yaml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
GO_NAME: signoz-community
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: community-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
|
||||
2
.github/workflows/build-enterprise.yaml
vendored
2
.github/workflows/build-enterprise.yaml
vendored
@@ -89,7 +89,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
GO_BUILD_CONTEXT: ./cmd/enterprise
|
||||
|
||||
2
.github/workflows/build-staging.yaml
vendored
2
.github/workflows/build-staging.yaml
vendored
@@ -95,7 +95,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: staging-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
GO_BUILD_CONTEXT: ./cmd/enterprise
|
||||
|
||||
20
.github/workflows/goci.yaml
vendored
20
.github/workflows/goci.yaml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_TEST_CONTEXT: ./...
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
fmt:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
lint:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
deps:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
semconv-generated:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: check-semconv-generated-files
|
||||
run: make semconv-check
|
||||
build:
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: qemu-install
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: aarch64-install
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: generate-openapi
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate openapi
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: generate-authz
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate authz
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: generate-web-settings
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config web-settings
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: generate-transaction-groups
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config transaction-groups
|
||||
|
||||
4
.github/workflows/gor-signoz-community.yaml
vendored
4
.github/workflows/gor-signoz-community.yaml
vendored
@@ -66,7 +66,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: cross-compilation-tools
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
|
||||
# copy the caches from build
|
||||
- name: get-sha
|
||||
|
||||
4
.github/workflows/gor-signoz.yaml
vendored
4
.github/workflows/gor-signoz.yaml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: cross-compilation-tools
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
|
||||
# copy the caches from build
|
||||
- name: get-sha
|
||||
|
||||
@@ -10231,8 +10231,10 @@ components:
|
||||
having:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5Having'
|
||||
description: Accepted but currently ignored; the computed values are returned
|
||||
unfiltered.
|
||||
description: A filter on the formula values, referencing the formula by
|
||||
its name or as __result, for example F1 > 0. A time series keeps only
|
||||
the points that match and drops a series with none left; a scalar result
|
||||
keeps only the rows that match.
|
||||
legend:
|
||||
description: The display name template for each series; {{field}} placeholders
|
||||
are replaced by label values.
|
||||
@@ -32729,6 +32731,305 @@ paths:
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_anomaly_request_rate:
|
||||
description: 'anomaly is the one function with named arguments. seasonality
|
||||
says which past periods predict the current value: hourly, daily
|
||||
or weekly, daily when absent. z_score_threshold is how many standard
|
||||
deviations from the prediction count as an anomaly. It has no default:
|
||||
a request without it gets bounds of zero width, so send it. 3 is
|
||||
the value the UI sends. The response carries, next to the values,
|
||||
a predicted series, an upper and a lower bound series and an anomaly
|
||||
score series. The scoring runs in the enterprise build; the community
|
||||
build returns the series unchanged.'
|
||||
summary: 'anomaly: request rate scored against its daily pattern'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: http.server.duration.count
|
||||
spaceAggregation: sum
|
||||
timeAggregation: rate
|
||||
functions:
|
||||
- args:
|
||||
- name: seasonality
|
||||
value: daily
|
||||
- name: z_score_threshold
|
||||
value: 3
|
||||
name: anomaly
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 300s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_cumulativesum_errors_so_far:
|
||||
description: cumulativeSum replaces each point with the sum of every
|
||||
point up to and including it, so a per-step count becomes a running
|
||||
total whose first point is the first count. It takes no arguments.
|
||||
The total resets with the window, so a longer time range gives a
|
||||
larger final value; it is a view of the window, not a counter.
|
||||
summary: 'cumulativeSum: 5xx responses since the start of the window'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- alias: server_errors
|
||||
expression: count()
|
||||
filter:
|
||||
expression: http.response.status_code >= 500
|
||||
functions:
|
||||
- name: cumulativeSum
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: traces
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_cutoffmin_hide_idle_hosts:
|
||||
description: The four threshold functions take one number. cutOffMin
|
||||
and cutOffMax remove the points below or above the threshold, so
|
||||
an idle host disappears from the chart instead of drawing a line
|
||||
along the bottom. clampMin and clampMax keep the points and replace
|
||||
the value with the threshold, which caps spikes without hiding them.
|
||||
system.cpu.utilization is a ratio, so 0.05 is 5%.
|
||||
summary: 'cutOffMin: drop hosts below 5% CPU from the chart'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: system.cpu.utilization
|
||||
spaceAggregation: avg
|
||||
timeAggregation: avg
|
||||
functions:
|
||||
- args:
|
||||
- value: 0.05
|
||||
name: cutOffMin
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: host.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_ewma_smoothed_latency:
|
||||
description: 'ewma3, ewma5 and ewma7 replace each point with an exponentially
|
||||
weighted average of the point and the points before it. The number
|
||||
in the name is the window the default weight is derived from: alpha
|
||||
= 2 / (n + 1), so 0.5, 0.333 and 0.25. One optional argument overrides
|
||||
alpha; a value close to 1 follows the raw series, a value close
|
||||
to 0 smooths harder. 0.3 here smooths a little more than ewma5''s
|
||||
default.'
|
||||
summary: 'ewma5: smoothed p95 latency'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: http.server.duration.bucket
|
||||
spaceAggregation: p95
|
||||
functions:
|
||||
- args:
|
||||
- value: 0.3
|
||||
name: ewma5
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_fillzero_sparse_errors:
|
||||
description: A count over sparse events returns a point only for steps
|
||||
that had a match, so a chart of failed spans has gaps where nothing
|
||||
failed. fillZero adds a zero at every missing step. With no arguments
|
||||
the server fills the request window at the query's step, which is
|
||||
the common case. The three optional arguments are start, end and
|
||||
step in milliseconds, for callers that want to fill a different
|
||||
grid.
|
||||
summary: 'fillZero: a zero at every step with no failed spans'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- alias: failed_spans
|
||||
expression: count()
|
||||
filter:
|
||||
expression: has_error = true
|
||||
functions:
|
||||
- name: fillZero
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: traces
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_log10_network_bytes:
|
||||
description: log2 and log10 replace each value with its logarithm,
|
||||
so a loopback device moving kilobytes and an uplink moving gigabytes
|
||||
fit on one chart. They take no arguments. Zero and negative values
|
||||
have no logarithm and are not drawn. absolute, also without arguments,
|
||||
is the usual companion for a signed series before a logarithm.
|
||||
summary: 'log10: network throughput across devices that differ by
|
||||
orders of magnitude'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: system.network.io
|
||||
spaceAggregation: sum
|
||||
timeAggregation: rate
|
||||
filter:
|
||||
expression: direction = 'transmit'
|
||||
functions:
|
||||
- name: log10
|
||||
groupBy:
|
||||
- fieldContext: attribute
|
||||
name: device
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_median_despiked_request_rate:
|
||||
description: median3, median5 and median7 replace each point with
|
||||
the median of the surrounding 3, 5 or 7 points. A spike that lasts
|
||||
one step vanishes, a shift that lasts longer than half the window
|
||||
stays. The first and last half window, 2 points each for median5,
|
||||
have no full window and keep their values, so the newest spike stays
|
||||
visible; a series shorter than the window is returned unchanged.
|
||||
They take no arguments. Use them when the raw series is noisy and
|
||||
the chart is for a trend, not for catching the spike.
|
||||
summary: 'median5: request rate without one-step spikes'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: http.server.duration.count
|
||||
spaceAggregation: sum
|
||||
timeAggregation: rate
|
||||
functions:
|
||||
- name: median5
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_runningdiff_disk_growth:
|
||||
description: 'runningDiff turns a gauge into the change between consecutive
|
||||
points, so a filesystem usage gauge becomes bytes written per step.
|
||||
It takes no arguments and drops the first point of the window, since
|
||||
that point has nothing to difference against; the server reads one
|
||||
step before the window to keep the first interval. Functions apply
|
||||
in order: clampMin with 0 runs on the differences and replaces the
|
||||
negative value a cleanup or a volume reset produces with zero.'
|
||||
summary: 'runningDiff then clampMin: bytes written to disk per step'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: system.filesystem.usage
|
||||
spaceAggregation: sum
|
||||
timeAggregation: latest
|
||||
filter:
|
||||
expression: state = 'used'
|
||||
functions:
|
||||
- name: runningDiff
|
||||
- args:
|
||||
- value: 0
|
||||
name: clampMin
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: host.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_timeshift_week_over_week:
|
||||
description: timeShift moves every point of its query forward by the
|
||||
argument, in seconds, so a series read from the past lands on the
|
||||
current timestamps. B here reads the hour exactly seven days (604800
|
||||
s) before the requested window and comes back on this week's timestamps,
|
||||
so it overlays A on a chart and the formula can subtract it point
|
||||
by point. The argument is positive; it says how far back to read.
|
||||
summary: 'timeShift: this week''s error logs next to last week''s'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- alias: errors
|
||||
expression: count()
|
||||
filter:
|
||||
expression: severity_text = 'ERROR'
|
||||
name: A
|
||||
signal: logs
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
- spec:
|
||||
aggregations:
|
||||
- alias: errors
|
||||
expression: count()
|
||||
filter:
|
||||
expression: severity_text = 'ERROR'
|
||||
functions:
|
||||
- args:
|
||||
- value: 604800
|
||||
name: timeShift
|
||||
name: B
|
||||
signal: logs
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
- spec:
|
||||
expression: A - B
|
||||
name: F1
|
||||
type: builder_formula
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
logs_raw:
|
||||
summary: 'Raw: fetch raw log records'
|
||||
value:
|
||||
|
||||
@@ -7079,7 +7079,7 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
*/
|
||||
functions?: Querybuildertypesv5FunctionDTO[] | null;
|
||||
/**
|
||||
* @description Accepted but currently ignored; the computed values are returned unfiltered.
|
||||
* @description A filter on the formula values, referencing the formula by its name or as __result, for example F1 > 0. A time series keeps only the points that match and drops a series with none left; a scalar result keeps only the rows that match.
|
||||
*/
|
||||
having?: Querybuildertypesv5HavingDTO;
|
||||
/**
|
||||
|
||||
@@ -21,14 +21,15 @@ import {
|
||||
useListDashboardsForUserV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
getListRulesQueryKey,
|
||||
useListRules,
|
||||
getListRulesV3QueryKey,
|
||||
useListRulesV3,
|
||||
} from 'api/generated/services/rules';
|
||||
import type {
|
||||
DashboardtypesListedDashboardForUserV2DTO,
|
||||
ListDashboardsForUserV2200,
|
||||
ListDashboardsForUserV2Params,
|
||||
ListRules200,
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useQueryService } from 'hooks/useQueryService';
|
||||
@@ -179,6 +180,9 @@ const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
|
||||
*/
|
||||
const DASHBOARD_LIST_PARAMS: ListDashboardsForUserV2Params = { limit: 1000 };
|
||||
|
||||
/** Same as dashboards. 200 is the most the rules list returns per call. */
|
||||
const ALERT_RULE_LIST_PARAMS: ListRulesV3Params = { limit: 200 };
|
||||
|
||||
function dashboardTitle(
|
||||
dashboard: DashboardtypesListedDashboardForUserV2DTO,
|
||||
): string {
|
||||
@@ -755,7 +759,7 @@ export default function ChatInput({
|
||||
data: alertsResponse,
|
||||
isLoading: isAlertsLoading,
|
||||
isError: isAlertsError,
|
||||
} = useListRules({
|
||||
} = useListRulesV3(ALERT_RULE_LIST_PARAMS, {
|
||||
query: {
|
||||
enabled: isContextPickerOpen && activeContextCategory === 'Alerts',
|
||||
staleTime: Infinity,
|
||||
@@ -804,10 +808,10 @@ export default function ChatInput({
|
||||
}
|
||||
}
|
||||
if (ctx.type === 'alert' && ctx.resourceId) {
|
||||
const cached = queryClient.getQueryData<ListRules200>(
|
||||
getListRulesQueryKey(),
|
||||
const cached = queryClient.getQueryData<ListRulesV3200>(
|
||||
getListRulesV3QueryKey(ALERT_RULE_LIST_PARAMS),
|
||||
);
|
||||
const rule = cached?.data?.find((r) => r.id === ctx.resourceId);
|
||||
const rule = cached?.data.rules.find((r) => r.id === ctx.resourceId);
|
||||
if (rule?.alert) {
|
||||
return rule.alert;
|
||||
}
|
||||
@@ -835,8 +839,8 @@ export default function ChatInput({
|
||||
value: dashboardTitle(dashboard),
|
||||
})) ?? [],
|
||||
Alerts:
|
||||
alertsResponse?.data
|
||||
?.filter((alertRule) => Boolean(alertRule.alert))
|
||||
alertsResponse?.data.rules
|
||||
.filter((alertRule) => Boolean(alertRule.alert))
|
||||
.map((alertRule) => ({
|
||||
id: alertRule.id,
|
||||
value: alertRule.alert,
|
||||
@@ -1059,6 +1063,7 @@ export default function ChatInput({
|
||||
color="secondary"
|
||||
size="sm"
|
||||
id={`ai-context-tab-${category}`}
|
||||
testId={`ai-context-tab-${category}`}
|
||||
// Single stable panel id shared by every tab: only the
|
||||
// active category's tabpanel is rendered, so per-category
|
||||
// `aria-controls` ids would point at nonexistent nodes
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useListDashboardsForUserV2: (): unknown => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useQueryService', () => ({
|
||||
useQueryService: (): unknown => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useSpeechRecognition', () => ({
|
||||
useSpeechRecognition: (): unknown => ({
|
||||
isListening: false,
|
||||
isSupported: false,
|
||||
permission: 'prompt',
|
||||
start: jest.fn(),
|
||||
discard: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
|
||||
useAIAssistantAnalyticsContext: (): unknown => ({
|
||||
threadId: undefined,
|
||||
page: '/',
|
||||
mode: 'sidepane',
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import ChatInput from '../ChatInput';
|
||||
|
||||
describe('ChatInput — alerts context picker', () => {
|
||||
it('lists the rules from one v3 rules request', async () => {
|
||||
const [first, second] = alertRulesFixture;
|
||||
const requests: string[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
requests.push(req.url.search);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response([first, second], req.url.searchParams)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ChatInput onSend={jest.fn()} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
|
||||
await userEvent.click(await screen.findByTestId('ai-context-tab-Alerts'));
|
||||
|
||||
await expect(screen.findByText(first.alert)).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText(second.alert)).toBeInTheDocument();
|
||||
expect(requests).toStrictEqual(['?limit=200']);
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,12 @@ jest.mock('api/generated/services/dashboard', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
|
||||
getListRulesQueryKey: (): string[] => ['rules'],
|
||||
useListRulesV3: (): unknown => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
getListRulesV3QueryKey: (): string[] => ['rules'],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useQueryService', () => ({
|
||||
|
||||
@@ -18,7 +18,7 @@ import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
|
||||
import './styles.scss';
|
||||
import {
|
||||
invalidateGetRuleByID,
|
||||
invalidateListRules,
|
||||
invalidateListRulesV3,
|
||||
} from 'api/generated/services/rules';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
@@ -137,7 +137,7 @@ function Footer(): JSX.Element {
|
||||
{
|
||||
onSuccess: () => {
|
||||
void invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
void invalidateListRules(queryClient);
|
||||
void invalidateListRulesV3(queryClient);
|
||||
|
||||
toast.success('Alert rule updated successfully');
|
||||
safeNavigate('/alerts');
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { AppLink } from 'lib/router/AppLink';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import beaconUrl from '@/assets/Icons/beacon.svg';
|
||||
|
||||
import { getItemIcon } from '../constants';
|
||||
import { useHomeAlertRules } from './useHomeAlertRules';
|
||||
|
||||
export default function AlertRules({
|
||||
onUpdateChecklistDoneItem,
|
||||
@@ -28,49 +26,19 @@ export default function AlertRules({
|
||||
loadingUserPreferences: boolean;
|
||||
}): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const [rulesExist, setRulesExist] = useState(false);
|
||||
|
||||
const [sortedAlertRules, setSortedAlertRules] = useState<RuletypesRuleDTO[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const location = useAppLocation();
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
// Fetch Alerts
|
||||
const {
|
||||
data: alerts,
|
||||
isError,
|
||||
isLoading,
|
||||
} = useListRules({
|
||||
query: { cacheTime: 0 },
|
||||
});
|
||||
const { rules: alertRules, isError, isLoading } = useHomeAlertRules();
|
||||
const rulesExist = alertRules.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const rules = alerts?.data ?? [];
|
||||
setRulesExist(rules.length > 0);
|
||||
|
||||
const sortedRules = [...rules].sort((a, b) => {
|
||||
// First, prioritize firing alerts
|
||||
if (a.state === 'firing' && b.state !== 'firing') {
|
||||
return -1;
|
||||
}
|
||||
if (a.state !== 'firing' && b.state === 'firing') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Then sort by updatedAt timestamp
|
||||
return (
|
||||
new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
if (sortedRules.length > 0 && !loadingUserPreferences) {
|
||||
if (rulesExist && !loadingUserPreferences) {
|
||||
onUpdateChecklistDoneItem('SETUP_ALERTS');
|
||||
}
|
||||
|
||||
setSortedAlertRules(sortedRules.slice(0, 5));
|
||||
}, [alerts, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
}, [rulesExist, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
|
||||
const emptyStateCard = (): JSX.Element => (
|
||||
<div className="empty-state-container">
|
||||
@@ -95,7 +63,7 @@ export default function AlertRules({
|
||||
className="periscope-btn secondary"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Homepage: Create alert rule clicked', {});
|
||||
void logEvent('Homepage: Create alert rule clicked', {});
|
||||
}}
|
||||
>
|
||||
Create Alert Rule
|
||||
@@ -106,7 +74,7 @@ export default function AlertRules({
|
||||
type="link"
|
||||
className="learn-more-link"
|
||||
onClick={(): void => {
|
||||
logEvent('Homepage: Learn more clicked', {
|
||||
void logEvent('Homepage: Learn more clicked', {
|
||||
source: 'Alert Rules',
|
||||
});
|
||||
|
||||
@@ -125,26 +93,13 @@ export default function AlertRules({
|
||||
</div>
|
||||
);
|
||||
|
||||
const onEditHandler = (record: RuletypesRuleDTO) => (): void => {
|
||||
logEvent('Homepage: Alert clicked', {
|
||||
const onEditHandler = (record: RuletypesListableRuleDTO) => (): void => {
|
||||
void logEvent('Homepage: Alert clicked', {
|
||||
ruleId: record.id,
|
||||
ruleName: record.alert,
|
||||
ruleState: record.state,
|
||||
});
|
||||
|
||||
const compositeQuery = mapQueryDataFromApi(
|
||||
toCompositeMetricQuery(record.condition.compositeQuery),
|
||||
);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const panelType = record.condition.compositeQuery.panelType;
|
||||
if (panelType) {
|
||||
params.set(QueryParams.panelTypes, panelType);
|
||||
}
|
||||
|
||||
params.set(QueryParams.ruleId, record.id);
|
||||
|
||||
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
@@ -153,11 +108,12 @@ export default function AlertRules({
|
||||
const renderAlertRules = (): JSX.Element => (
|
||||
<div className="alert-rules-container home-data-item-container">
|
||||
<div className="alert-rules-list">
|
||||
{sortedAlertRules.map((rule) => (
|
||||
{alertRules.map((rule) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="alert-rule-item home-data-item"
|
||||
data-testid={`home-alert-rule-${rule.id}`}
|
||||
key={rule.id}
|
||||
onClick={onEditHandler(rule)}
|
||||
onKeyDown={(e): void => {
|
||||
@@ -239,7 +195,7 @@ export default function AlertRules({
|
||||
type="link"
|
||||
className="periscope-btn link learn-more-link"
|
||||
onClick={(): void => {
|
||||
logEvent('Homepage: All alert rules clicked', {});
|
||||
void logEvent('Homepage: All alert rules clicked', {});
|
||||
}}
|
||||
>
|
||||
All Alert Rules <ArrowRight size={12} />
|
||||
|
||||
64
frontend/src/container/Home/AlertRules/useHomeAlertRules.ts
Normal file
64
frontend/src/container/Home/AlertRules/useHomeAlertRules.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useListRulesV3 } from 'api/generated/services/rules';
|
||||
import {
|
||||
type ListRulesV3200,
|
||||
type ListRulesV3Params,
|
||||
RuletypesAlertStateDTO,
|
||||
type RuletypesListableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
|
||||
|
||||
const HOME_ALERT_RULES_LIMIT = 5;
|
||||
|
||||
const FIRING_RULES_PARAMS: ListRulesV3Params = {
|
||||
states: [RuletypesAlertStateDTO.firing],
|
||||
limit: HOME_ALERT_RULES_LIMIT,
|
||||
};
|
||||
|
||||
interface HomeAlertRules {
|
||||
rules: RuletypesListableRuleDTO[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export function useHomeAlertRules(): HomeAlertRules {
|
||||
const firing = useListRulesV3(FIRING_RULES_PARAMS, {
|
||||
query: {
|
||||
cacheTime: 0,
|
||||
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
|
||||
queryFn: ({ signal }) =>
|
||||
GeneratedAPIInstance<ListRulesV3200>({
|
||||
url: '/api/v3/rules',
|
||||
method: 'GET',
|
||||
params: FIRING_RULES_PARAMS,
|
||||
paramsSerializer: { indexes: null },
|
||||
signal,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const firingRules = firing.data?.data.rules;
|
||||
const latest = useListRulesV3(
|
||||
{ limit: HOME_ALERT_RULES_LIMIT },
|
||||
{
|
||||
query: {
|
||||
cacheTime: 0,
|
||||
enabled:
|
||||
firingRules !== undefined && firingRules.length < HOME_ALERT_RULES_LIMIT,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const rules = useMemo(() => {
|
||||
const firingIds = new Set(firingRules?.map(({ id }) => id));
|
||||
const others = (latest.data?.data.rules ?? []).filter(
|
||||
({ id }) => !firingIds.has(id),
|
||||
);
|
||||
return [...(firingRules ?? []), ...others].slice(0, HOME_ALERT_RULES_LIMIT);
|
||||
}, [firingRules, latest.data]);
|
||||
|
||||
return {
|
||||
rules,
|
||||
isLoading: firing.isLoading || latest.isLoading,
|
||||
isError: firing.isError || latest.isError,
|
||||
};
|
||||
}
|
||||
119
frontend/src/container/Home/__tests__/AlertRules.test.tsx
Normal file
119
frontend/src/container/Home/__tests__/AlertRules.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
RuletypesAlertStateDTO,
|
||||
type RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import AlertRules from '../AlertRules/AlertRules';
|
||||
|
||||
jest.mock('lib/router/navigation', () => ({
|
||||
...jest.requireActual('lib/router/navigation'),
|
||||
navigate: jest.fn(),
|
||||
}));
|
||||
|
||||
const rule = (id: string, state: RuletypesAlertStateDTO): RuletypesRuleDTO => ({
|
||||
...alertRulesFixture[0],
|
||||
id,
|
||||
alert: `${id} rule`,
|
||||
state,
|
||||
});
|
||||
|
||||
const burning = rule('burning', RuletypesAlertStateDTO.firing);
|
||||
const quiet = rule('quiet', RuletypesAlertStateDTO.inactive);
|
||||
const waiting = rule('waiting', RuletypesAlertStateDTO.pending);
|
||||
|
||||
const serveRules = (
|
||||
rules: RuletypesRuleDTO[],
|
||||
rulesForLatest = rules,
|
||||
): string[] => {
|
||||
const requestedStates: string[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
const states = req.url.searchParams.getAll('states').join();
|
||||
requestedStates.push(states);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
listRulesV3Response(
|
||||
states === 'firing' ? rules : rulesForLatest,
|
||||
req.url.searchParams,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return requestedStates;
|
||||
};
|
||||
|
||||
const renderAlertRules = (): void => {
|
||||
render(
|
||||
<AlertRules
|
||||
onUpdateChecklistDoneItem={jest.fn()}
|
||||
loadingUserPreferences={false}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
const renderedRuleIds = (): (string | undefined)[] =>
|
||||
screen.getAllByTestId(/^home-alert-rule-/).map((row) => row.dataset.testid);
|
||||
|
||||
describe('Home alert rules', () => {
|
||||
it('lists firing rules first, then the latest others, and opens the clicked rule', async () => {
|
||||
const requestedStates = serveRules([quiet, burning, waiting]);
|
||||
renderAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('home-alert-rule-waiting'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(renderedRuleIds()).toStrictEqual([
|
||||
'home-alert-rule-burning',
|
||||
'home-alert-rule-quiet',
|
||||
'home-alert-rule-waiting',
|
||||
]);
|
||||
expect(requestedStates).toStrictEqual(['firing', '']);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByTestId('home-alert-rule-burning'));
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
`${ROUTES.ALERT_OVERVIEW}?${QueryParams.ruleId}=burning`,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the latest rules request when 5 rules are firing', async () => {
|
||||
const firing = Array.from({ length: 5 }, (_, index) =>
|
||||
rule(`firing-${index}`, RuletypesAlertStateDTO.firing),
|
||||
);
|
||||
const requestedStates = serveRules([quiet, ...firing]);
|
||||
renderAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('home-alert-rule-firing-4'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(renderedRuleIds()).toStrictEqual(
|
||||
firing.map(({ id }) => `home-alert-rule-${id}`),
|
||||
);
|
||||
expect(requestedStates).toStrictEqual(['firing']);
|
||||
});
|
||||
|
||||
it('shows a rule once when it stops firing between the two requests', async () => {
|
||||
const settled = { ...burning, state: RuletypesAlertStateDTO.inactive };
|
||||
serveRules([burning], [settled, quiet]);
|
||||
renderAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('home-alert-rule-quiet'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(renderedRuleIds()).toStrictEqual([
|
||||
'home-alert-rule-burning',
|
||||
'home-alert-rule-quiet',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
useDeleteDowntimeScheduleByID,
|
||||
useListDowntimeSchedules,
|
||||
} from 'api/generated/services/downtimeschedules';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import type { AlertmanagertypesPlannedMaintenanceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ColumnSelector } from 'components/TanStackTableView';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import dayjs from 'dayjs';
|
||||
import { useListAllRules } from 'hooks/alerts/useListAllRules';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
@@ -55,9 +55,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching: isFetchingAlerts,
|
||||
} = useListRules({
|
||||
query: { cacheTime: 0 },
|
||||
});
|
||||
} = useListAllRules({ cacheTime: 0 });
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const { user } = useAppContext();
|
||||
@@ -73,7 +71,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
const downtimeSchedules = useListDowntimeSchedules();
|
||||
const alertOptions = React.useMemo(
|
||||
() =>
|
||||
alertsData?.data?.map((i) => ({
|
||||
alertsData?.map((i) => ({
|
||||
label: i.alert,
|
||||
value: i.id,
|
||||
})),
|
||||
@@ -181,7 +179,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
};
|
||||
|
||||
const refetchAllSchedules = (): void => {
|
||||
downtimeSchedules.refetch();
|
||||
void downtimeSchedules.refetch();
|
||||
};
|
||||
|
||||
const {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fireEvent, screen } from '@testing-library/react';
|
||||
import type {
|
||||
ListDowntimeSchedules200,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesListableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AlertmanagertypesRepeatOnDTO,
|
||||
@@ -126,19 +127,19 @@ jest.mock('api/generated/services/downtimeschedules', () => ({
|
||||
}));
|
||||
|
||||
let mockRulesState: {
|
||||
data?: { data: Array<{ id: number | string; alert: string }> };
|
||||
data?: Pick<RuletypesListableRuleDTO, 'id' | 'alert'>[];
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
} = {
|
||||
data: { data: [] },
|
||||
data: [],
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
};
|
||||
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
useListRules: (): typeof mockRulesState => mockRulesState,
|
||||
jest.mock('hooks/alerts/useListAllRules', () => ({
|
||||
useListAllRules: (): typeof mockRulesState => mockRulesState,
|
||||
}));
|
||||
|
||||
describe('PlannedDowntime Component', () => {
|
||||
@@ -151,7 +152,7 @@ describe('PlannedDowntime Component', () => {
|
||||
});
|
||||
mockDowntimeQueryResult.data = MOCK_DOWNTIME_RESPONSE;
|
||||
mockRulesState = {
|
||||
data: { data: [] },
|
||||
data: [],
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
@@ -362,7 +363,7 @@ describe('PlannedDowntime Component', () => {
|
||||
|
||||
it('shows "Unknown rule" when scoped rule IDs cannot be resolved (Bug 56)', async () => {
|
||||
mockRulesState = {
|
||||
data: { data: [] },
|
||||
data: [],
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
@@ -387,14 +388,7 @@ describe('PlannedDowntime Component', () => {
|
||||
|
||||
it('shows resolved alert rule name when scoped rule ID exists', async () => {
|
||||
mockRulesState = {
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'rule-42',
|
||||
alert: 'Disk Space High',
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
data: [{ id: 'rule-42', alert: 'Disk Space High' }],
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { tags } from '@lezer/highlight';
|
||||
import { copilotDarkStyle } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLightStyle } from '@uiw/codemirror-theme-github';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { act, render, screen } from 'tests/test-utils';
|
||||
|
||||
import SavedViewHighlightedExpression from '../SavedViewHighlightedExpression';
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseIsDarkMode = useIsDarkMode as jest.MockedFunction<
|
||||
typeof useIsDarkMode
|
||||
>;
|
||||
|
||||
const EXPRESSION = "service.name = 'frontend'";
|
||||
|
||||
function stringColor(styles: typeof copilotDarkStyle): string {
|
||||
return (styles ?? []).find(({ tag }) =>
|
||||
(Array.isArray(tag) ? tag : [tag]).includes(tags.string),
|
||||
)?.color as string;
|
||||
}
|
||||
|
||||
describe('SavedViewHighlightedExpression', () => {
|
||||
it.each([
|
||||
['dark', true, copilotDarkStyle],
|
||||
['light', false, githubLightStyle],
|
||||
])('colours a string value with the %s theme', (_, isDarkMode, styles) => {
|
||||
mockUseIsDarkMode.mockReturnValue(isDarkMode);
|
||||
|
||||
render(<SavedViewHighlightedExpression expression={EXPRESSION} />);
|
||||
|
||||
expect(screen.getByText("'frontend'")).toHaveStyle({
|
||||
color: stringColor(styles),
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the new theme colours when the theme changes', () => {
|
||||
let setIsDarkMode: (isDarkMode: boolean) => void = () => {};
|
||||
function useSwitchableDarkMode(): boolean {
|
||||
const [isDarkMode, setter] = useState(true);
|
||||
setIsDarkMode = setter;
|
||||
return isDarkMode;
|
||||
}
|
||||
mockUseIsDarkMode.mockImplementation(useSwitchableDarkMode);
|
||||
render(<SavedViewHighlightedExpression expression={EXPRESSION} />);
|
||||
|
||||
act(() => setIsDarkMode(false));
|
||||
|
||||
expect(screen.getByText("'frontend'")).toHaveStyle({
|
||||
color: stringColor(githubLightStyle),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,19 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AuthtypesTransactionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -222,6 +230,31 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not open when the row menu is pressed before the delay', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS / 2);
|
||||
await user.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
|
||||
it('closes on escape, leaving nothing of the popover behind', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await renderPanel();
|
||||
await openCard('Errors');
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
expect(card()).toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-radix-popper-content-wrapper]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content', () => {
|
||||
@@ -312,6 +345,78 @@ describe('SavedViewHoverCard', () => {
|
||||
await waitFor(() => expect(fetches).toBe(2));
|
||||
});
|
||||
|
||||
it('shows a skeleton, not the failure, while the view loads', async () => {
|
||||
let requested = false;
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, async (_req, res, ctx) => {
|
||||
requested = true;
|
||||
await gate;
|
||||
return res(ctx.json({ status: 'success', data: errors }));
|
||||
}),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
await waitFor(() => expect(requested).toBe(true));
|
||||
|
||||
expect(
|
||||
screen.getByTestId('saved-view-hover-card-loading'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-view-hover-card-error')).toBeNull();
|
||||
|
||||
release();
|
||||
await screen.findByTestId('saved-view-hover-card-query');
|
||||
});
|
||||
|
||||
it('shows a skeleton while the read check is pending', async () => {
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const isRead = (transaction: AuthtypesTransactionDTO): boolean =>
|
||||
transaction.relation === 'read';
|
||||
const isReadAll = (transaction: AuthtypesTransactionDTO): boolean =>
|
||||
isRead(transaction) && transaction.object.selector === '*';
|
||||
// Read on every view would skip the per-view check, so it is denied.
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
if (
|
||||
payload.some(
|
||||
(transaction) => isRead(transaction) && !isReadAll(transaction),
|
||||
)
|
||||
) {
|
||||
await gate;
|
||||
}
|
||||
return res(
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map((transaction) => !isReadAll(transaction)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('saved-view-hover-card-loading'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-view-hover-card-query')).toBeNull();
|
||||
|
||||
release();
|
||||
await screen.findByTestId('saved-view-hover-card-query');
|
||||
});
|
||||
|
||||
it('says the view failed to load when its fetch fails', async () => {
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
|
||||
@@ -339,6 +339,45 @@ describe('SavedViews - AuthZ', () => {
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('disables save as new without create, keeping update', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(SavedViewCreatePermission));
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('Errors'),
|
||||
);
|
||||
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
await userEvent.click(await screen.findByTestId('saved-views-save-changes'));
|
||||
|
||||
await expect(
|
||||
settledRow('saved-views-save-changes-item-save-as-new'),
|
||||
).resolves.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-save-changes-item-update'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('offers clear on a view it cannot read', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'No view access',
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says no access when the check denies read, even if the server returns the view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
@@ -75,4 +76,39 @@ describe('SavedViewsHeader last used view', () => {
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not bring the view back once it is cleared', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
await waitForView('Errors');
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-clear'));
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
});
|
||||
|
||||
it('forgets a last used view that no longer loads', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}',
|
||||
),
|
||||
).toStrictEqual({}),
|
||||
);
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
});
|
||||
|
||||
it('starts on a bare explorer when the stored last used views are unreadable', async () => {
|
||||
localStorage.setItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS, '{not json');
|
||||
mockSavedViewsApi([errors]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { act, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
@@ -19,7 +23,7 @@ import {
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
@@ -43,6 +47,27 @@ function renderHeader(
|
||||
);
|
||||
}
|
||||
|
||||
// Edits the current query without running it, so it is not the staged one.
|
||||
function EditCurrentQuery(): JSX.Element {
|
||||
const { currentQuery, handleSetQueryData } = useQueryBuilder();
|
||||
return (
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
testId="edit-current-query"
|
||||
onClick={(): void =>
|
||||
handleSetQueryData(0, {
|
||||
...currentQuery.builder.queryData[0],
|
||||
filter: { expression: 'unstaged = true' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit query
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const modal = (): HTMLElement => screen.getByTestId('save-view-modal');
|
||||
const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
|
||||
|
||||
@@ -160,6 +185,76 @@ describe('SavedViewsHeader saving', () => {
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not create a view from a blank name submitted with Enter', async () => {
|
||||
const requests = mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await clickCreate();
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), ' {enter}');
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
|
||||
expect(requests.created).toHaveLength(0);
|
||||
expect(isModalOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores Enter while the view is being saved', async () => {
|
||||
let creates = 0;
|
||||
mockSavedViewsApi([]);
|
||||
server.use(
|
||||
rest.post(API, (_req, res, ctx) => {
|
||||
creates += 1;
|
||||
return res(
|
||||
ctx.delay(400),
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: { id: 'view-new' } }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await clickCreate();
|
||||
await submitName('Slow checkout');
|
||||
await waitFor(() => expect(creates).toBe(1));
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), '{enter}');
|
||||
|
||||
await waitFor(() => expect(isModalOpen()).toBe(false));
|
||||
expect(creates).toBe(1);
|
||||
});
|
||||
|
||||
it('saves the query that was run, not edits made since', async () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderWithExplorerProviders(
|
||||
<>
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
/>
|
||||
<EditCurrentQuery />
|
||||
</>,
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('edit-current-query'));
|
||||
await clickCreate();
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() => expect(requests.created).toHaveLength(1));
|
||||
const body = requests.created[0] as {
|
||||
spec: { queries: { spec: { filter?: { expression?: string } } }[] };
|
||||
};
|
||||
expect(body.spec.queries[0].spec.filter?.expression ?? '').not.toContain(
|
||||
'unstaged',
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify('view-new'),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
decodeURIComponent(urlParam(history, QueryParams.compositeQuery) ?? ''),
|
||||
).not.toContain('unstaged');
|
||||
});
|
||||
|
||||
it('cancel closes the modal without saving', async () => {
|
||||
const requests = mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
@@ -231,6 +326,37 @@ describe('SavedViewsHeader saving', () => {
|
||||
expect(requests.created).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('update that fails reports the error and never says the view was updated', async () => {
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const requests = mockSavedViewsApi([errors], { updateStatus: 500 });
|
||||
await openDirtyView(errors);
|
||||
|
||||
await chooseFromSaveMenu('Update selected view');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
expect(requests.updated).toHaveLength(1);
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update keeps the color of the open view', async () => {
|
||||
const base = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const colored = {
|
||||
...base,
|
||||
spec: { ...base.spec, display: { color: '#e5484d' } },
|
||||
};
|
||||
const requests = mockSavedViewsApi([colored]);
|
||||
await openDirtyView(colored);
|
||||
|
||||
await chooseFromSaveMenu('Update selected view');
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
const { body } = requests.updated[0] as {
|
||||
body: { spec: { display?: { color?: string } } };
|
||||
};
|
||||
expect(body.spec.display?.color).toBe('#e5484d');
|
||||
});
|
||||
|
||||
describe('view name toggle with unsaved changes', () => {
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
@@ -219,6 +221,75 @@ describe('SavedViewsPanel list', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('lists only the views of its own source', async () => {
|
||||
mockSavedViewsApi([
|
||||
mine,
|
||||
makeView({
|
||||
id: 'view-9',
|
||||
displayName: 'Noisy logs',
|
||||
source: SavedviewtypesSourceDTO.logs,
|
||||
}),
|
||||
]);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
expect(rowNames('saved-views-created-by-me')).toStrictEqual(['Errors']);
|
||||
});
|
||||
|
||||
describe('last used view', () => {
|
||||
const lastUsedViews = (): unknown =>
|
||||
JSON.parse(localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}');
|
||||
const logsEntry = { logs: { key: 'logs-view', value: 'Noisy logs' } };
|
||||
|
||||
it('is remembered under the panel source, keeping the other sources', async () => {
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
|
||||
JSON.stringify(logsEntry),
|
||||
);
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
renderPanel();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await waitFor(() => expectUsable(rowName('Slow spans')));
|
||||
|
||||
await userEvent.click(rowName('Slow spans'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastUsedViews()).toStrictEqual({
|
||||
...logsEntry,
|
||||
traces: { key: alsoMine.id, value: 'Slow spans' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('is filed under the source of the panel it was opened from', async () => {
|
||||
const logsView = makeView({
|
||||
id: 'view-9',
|
||||
displayName: 'Noisy logs',
|
||||
source: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
});
|
||||
mockSavedViewsApi([logsView]);
|
||||
renderWithExplorerProviders(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.logs}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
explorerUrl(ROUTES.LOGS_EXPLORER, {}),
|
||||
DataSource.LOGS,
|
||||
);
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await waitFor(() => expectUsable(rowName('Noisy logs')));
|
||||
|
||||
await userEvent.click(rowName('Noisy logs'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastUsedViews()).toStrictEqual({
|
||||
logs: { key: logsView.id, value: 'Noisy logs' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('offers to create a view when there are none, and opens it', async () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderPanel();
|
||||
|
||||
@@ -4,11 +4,13 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectRowUsable,
|
||||
explorerUrl,
|
||||
@@ -181,6 +183,87 @@ describe('SavedViewsPanel row menu', () => {
|
||||
await waitFor(() => expect(row('Slow checkout spans')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renames from the stored view, not a stale list row', async () => {
|
||||
const views = [errors, slow];
|
||||
const requests = mockSavedViewsApi(views);
|
||||
renderPanelWithHeader();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const stored = makeView({
|
||||
id: slow.id,
|
||||
displayName: 'Slow spans',
|
||||
expression: 'duration_nano > 5000',
|
||||
});
|
||||
views[1] = stored;
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, 'Slow checkout spans');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
expect(requests.updated[0].body).toMatchObject({
|
||||
spec: { ...stored.spec, displayName: 'Slow checkout spans' },
|
||||
});
|
||||
});
|
||||
|
||||
it('renames from the list row when the view cannot be read', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow]);
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(403), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, 'Slow checkout spans');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
expect(requests.updated[0].body).toMatchObject({
|
||||
spec: { ...slow.spec, displayName: 'Slow checkout spans' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not rename when the stored view fails to load', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow]);
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.type(name, ' 2');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(requests.updated).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores Enter while the stored view loads', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow], { getDelay: 300 });
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.type(name, ' 2{enter}');
|
||||
await userEvent.type(name, '{enter}');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'View updated',
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
expect(requests.updated).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows the new name in the header when the open view is renamed', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderPanelWithHeader(viewUrl(PATH, errors));
|
||||
@@ -236,4 +319,19 @@ describe('SavedViewsPanel row menu', () => {
|
||||
);
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('My view'));
|
||||
});
|
||||
|
||||
it('keeps the open view when another view is deleted', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
const { history } = renderPanelWithHeader(viewUrl(PATH, errors));
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('Errors'));
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Delete');
|
||||
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
|
||||
|
||||
await waitFor(() => expect(row('Slow spans')).toBeUndefined());
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(errors.id),
|
||||
);
|
||||
expect(chip()).toHaveTextContent('Errors');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import store from 'store';
|
||||
import { act, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectRowUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
renderWithExplorerProviders,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const mockCopy: jest.MockedFunction<(text: string) => void> = jest.fn();
|
||||
|
||||
// copy-to-clipboard falls back to window.prompt in jsdom.
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): [unknown, typeof mockCopy] => [null, mockCopy],
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
|
||||
|
||||
// A store whose global time can change while the panel stays mounted.
|
||||
function renderPanelWithTime(selectedTime: GlobalReducer['selectedTime']): {
|
||||
setTime: (time: Partial<GlobalReducer>) => void;
|
||||
} {
|
||||
let globalTime: GlobalReducer = {
|
||||
...store.getState().globalTime,
|
||||
selectedTime,
|
||||
};
|
||||
const timeStore = configureStore([thunk])(() => ({
|
||||
...store.getState(),
|
||||
globalTime,
|
||||
}));
|
||||
|
||||
renderWithExplorerProviders(
|
||||
<Provider store={timeStore}>
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
</Provider>,
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
return {
|
||||
setTime: (time): void => {
|
||||
globalTime = { ...globalTime, ...time };
|
||||
act(() => {
|
||||
timeStore.dispatch({ type: 'TEST_TIME_CHANGED' });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const row = (name: string): HTMLElement =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
const relativeTimeOf = (link: string): string | null =>
|
||||
new URL(link, window.location.origin).searchParams.get(
|
||||
QueryParams.relativeTime,
|
||||
);
|
||||
|
||||
describe('SavedViewsPanel time range', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockCopy.mockClear();
|
||||
mockSavedViewsApi([slow]);
|
||||
});
|
||||
|
||||
it('moves the row link to the new time range when it changes', async () => {
|
||||
const { setTime } = renderPanelWithTime('1h');
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
|
||||
expect(relativeTimeOf(name.getAttribute('href') as string)).toBe('1h');
|
||||
|
||||
setTime({ selectedTime: '6h' });
|
||||
|
||||
expect(relativeTimeOf(name.getAttribute('href') as string)).toBe('6h');
|
||||
});
|
||||
|
||||
it('copies a link in the time range that is selected when it is copied', async () => {
|
||||
const { setTime } = renderPanelWithTime('1h');
|
||||
await screen.findByTestId('saved-views-list');
|
||||
setTime({ selectedTime: '6h' });
|
||||
|
||||
await userEvent.click(
|
||||
within(row('Slow spans')).getByTestId('saved-views-row-menu'),
|
||||
);
|
||||
const copyLink = await screen.findByTestId(
|
||||
'saved-views-row-menu-item-copyLink',
|
||||
);
|
||||
await waitFor(() => expectRowUsable(copyLink), MENU_CHECK_TIMEOUT);
|
||||
await userEvent.click(copyLink);
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledTimes(1);
|
||||
expect(relativeTimeOf(mockCopy.mock.calls[0][0])).toBe('6h');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
getGetSavedViewQueryOptions,
|
||||
updateSavedView,
|
||||
useDeleteSavedView,
|
||||
useUpdateSavedView,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
@@ -45,8 +46,39 @@ export function useSavedViewRowActions(
|
||||
const timeSearch = useTimeSearchParams();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
|
||||
useUpdateSavedView();
|
||||
// The update replaces the whole spec, so it starts from the stored view, not
|
||||
// the list row, which can be stale. Without read on the view, the list row is
|
||||
// the only copy there is.
|
||||
const { mutateAsync: renameSavedView, isLoading: isRenaming } = useMutation(
|
||||
async ({
|
||||
view,
|
||||
displayName,
|
||||
}: {
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
displayName: string;
|
||||
}) => {
|
||||
let { spec } = view;
|
||||
try {
|
||||
({
|
||||
data: { spec },
|
||||
} = await queryClient.fetchQuery(
|
||||
getGetSavedViewQueryOptions({ id: view.id }),
|
||||
));
|
||||
} catch (error) {
|
||||
if ((error as ErrorType<unknown>).response?.status !== 403) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await updateSavedView(
|
||||
{ id: view.id },
|
||||
{
|
||||
source,
|
||||
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
|
||||
spec: { ...spec, displayName },
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
|
||||
useDeleteSavedView();
|
||||
|
||||
@@ -77,14 +109,7 @@ export function useSavedViewRowActions(
|
||||
displayName: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await updateSavedView({
|
||||
pathParams: { id: view.id },
|
||||
data: {
|
||||
source,
|
||||
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
|
||||
spec: { ...view.spec, displayName },
|
||||
},
|
||||
});
|
||||
await renameSavedView({ view, displayName });
|
||||
await invalidateSavedViews(queryClient, source, view.id);
|
||||
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.ViewDetailsEdited, {
|
||||
@@ -96,7 +121,7 @@ export function useSavedViewRowActions(
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateSavedView, source, queryClient, logSavedViewEvent],
|
||||
[renameSavedView, source, queryClient, logSavedViewEvent],
|
||||
);
|
||||
|
||||
// The deleted view's own query is left alone: refetching it would fail
|
||||
|
||||
@@ -127,6 +127,48 @@ describe('hasUnsavedViewChanges', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is clean while no query is staged', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: null,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is dirty when the columns are reordered', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: { ...options, selectColumns: [columns[1], columns[0]] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is dirty when a column is swapped for another', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: {
|
||||
...options,
|
||||
selectColumns: [
|
||||
columns[0],
|
||||
{ name: 'duration_nano', key: 'duration_nano' } as TelemetryFieldKey,
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a traces view without columns as the default columns', () => {
|
||||
const bare = viewFrom(
|
||||
toSavedViewSpec({
|
||||
@@ -184,6 +226,21 @@ describe('hasUnsavedViewChanges', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['format', { format: 'table' as LogViewMode }],
|
||||
['font size', { fontSize: FontSize.LARGE }],
|
||||
])('is dirty when only the %s changes', (_, change) => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: logsView,
|
||||
stagedQuery: logsStaged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
options: { selectColumns: shownColumns, ...formatting, ...change },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a view without formatting as the default formatting', () => {
|
||||
const bare = viewFrom(
|
||||
toSavedViewSpec({
|
||||
@@ -295,6 +352,137 @@ describe('hasUnsavedViewChanges', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['point', 'attribute'],
|
||||
['spanfield', 'span'],
|
||||
['logfield', 'log'],
|
||||
['tracefield', 'trace'],
|
||||
])(
|
||||
'reads a group-by staged as %s and stored as %s as the same field',
|
||||
(legacyContext, storedContext) => {
|
||||
const base = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.METRICS, ''),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
displayName: 'By os',
|
||||
}),
|
||||
);
|
||||
const [storedQuery] = base.spec.queries;
|
||||
const storedView = {
|
||||
...base,
|
||||
spec: {
|
||||
...base.spec,
|
||||
queries: [
|
||||
{
|
||||
...storedQuery,
|
||||
spec: {
|
||||
...storedQuery.spec,
|
||||
groupBy: [
|
||||
{
|
||||
name: 'os.type',
|
||||
fieldContext: storedContext,
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
const opened = stagedFrom(storedView, DataSource.METRICS);
|
||||
const stagedLegacy = {
|
||||
...opened,
|
||||
builder: {
|
||||
...opened.builder,
|
||||
queryData: opened.builder.queryData.map((queryData) => ({
|
||||
...queryData,
|
||||
groupBy: [{ key: 'os.type', dataType: 'string', type: legacyContext }],
|
||||
})),
|
||||
},
|
||||
} as Query;
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: storedView,
|
||||
stagedQuery: stagedLegacy,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
sourcePage: SavedviewtypesSourceDTO.metrics,
|
||||
}),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
// The query still staged after a save keeps the builder's values, while
|
||||
// the server returns the view without them.
|
||||
describe('right after saving a query that carries builder defaults', () => {
|
||||
const withPatch = (
|
||||
dataSource: DataSource,
|
||||
patch: Partial<Query['builder']['queryData'][number]>,
|
||||
): Query => {
|
||||
const base = query(dataSource, '');
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: base.builder.queryData.map((queryData) => ({
|
||||
...queryData,
|
||||
...patch,
|
||||
})),
|
||||
},
|
||||
} as Query;
|
||||
};
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a zero offset on a list',
|
||||
DataSource.LOGS,
|
||||
SavedviewtypesSourceDTO.logs,
|
||||
PANEL_TYPES.LIST,
|
||||
{ offset: 0 },
|
||||
],
|
||||
[
|
||||
'an empty group-by description',
|
||||
DataSource.TRACES,
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
{
|
||||
groupBy: [
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'a function without arguments',
|
||||
DataSource.METRICS,
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
{ functions: [{ name: 'absolute', args: [], namedArgs: {} }] },
|
||||
],
|
||||
])('is clean with %s', (_, dataSource, sourcePage, panelType, patch) => {
|
||||
const saved = withPatch(
|
||||
dataSource,
|
||||
patch as Partial<Query['builder']['queryData'][number]>,
|
||||
);
|
||||
const savedView = viewFrom(
|
||||
toSavedViewSpec({ query: saved, panelType, displayName: 'Saved' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: savedView,
|
||||
stagedQuery: saved,
|
||||
panelType,
|
||||
sourcePage,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list order', () => {
|
||||
const withOrderBy = (
|
||||
staged: Query,
|
||||
|
||||
@@ -80,6 +80,66 @@ describe('toSavedViewSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the description and unit of a column, naming it by name before key', () => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
options: {
|
||||
selectColumns: [
|
||||
{
|
||||
name: 'duration_nano',
|
||||
key: 'durationNano',
|
||||
description: 'Span duration',
|
||||
unit: 'ns',
|
||||
},
|
||||
] as unknown as TelemetryFieldKey[],
|
||||
format: 'raw' as LogViewMode,
|
||||
maxLines: 2,
|
||||
fontSize: FontSize.SMALL,
|
||||
},
|
||||
});
|
||||
|
||||
expect(spec.selectedFields).toStrictEqual([
|
||||
{ name: 'duration_nano', description: 'Span duration', unit: 'ns' },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
PANEL_TYPES.LIST,
|
||||
SavedviewtypesPanelTypeDTO.list,
|
||||
Querybuildertypesv5RequestTypeDTO.raw,
|
||||
],
|
||||
[
|
||||
PANEL_TYPES.TABLE,
|
||||
SavedviewtypesPanelTypeDTO.table,
|
||||
Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
],
|
||||
[
|
||||
PANEL_TYPES.TRACE,
|
||||
SavedviewtypesPanelTypeDTO.trace,
|
||||
Querybuildertypesv5RequestTypeDTO.trace,
|
||||
],
|
||||
[
|
||||
PANEL_TYPES.PIE,
|
||||
SavedviewtypesPanelTypeDTO.graph,
|
||||
Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
],
|
||||
])(
|
||||
'stores the %s panel as panel type %s with request type %s',
|
||||
(panelType, storedPanelType, requestType) => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
panelType,
|
||||
displayName: 'Errors',
|
||||
});
|
||||
|
||||
expect(spec.panelType).toBe(storedPanelType);
|
||||
expect(spec.requestType).toBe(requestType);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the colour in display', () => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
|
||||
40
frontend/src/hooks/alerts/__tests__/useListAllRules.test.tsx
Normal file
40
frontend/src/hooks/alerts/__tests__/useListAllRules.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { useListAllRules } from '../useListAllRules';
|
||||
|
||||
const TOTAL = 250;
|
||||
|
||||
const rules = Array.from({ length: TOTAL }, (_, index) => ({
|
||||
...alertRulesFixture[0],
|
||||
id: `rule-${index}`,
|
||||
alert: `Rule ${index}`,
|
||||
}));
|
||||
|
||||
describe('useListAllRules', () => {
|
||||
it('fetches every page past the 200-rule cap', async () => {
|
||||
const offsets: string[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
offsets.push(req.url.searchParams.get('offset') ?? '');
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response(rules, req.url.searchParams)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useListAllRules(), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toHaveLength(TOTAL);
|
||||
expect(result.current.data?.[TOTAL - 1]?.id).toBe(`rule-${TOTAL - 1}`);
|
||||
expect(offsets.sort()).toStrictEqual(['0', '200']);
|
||||
});
|
||||
});
|
||||
40
frontend/src/hooks/alerts/useListAllRules.ts
Normal file
40
frontend/src/hooks/alerts/useListAllRules.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import {
|
||||
getListRulesV3QueryKey,
|
||||
listRulesV3,
|
||||
} from 'api/generated/services/rules';
|
||||
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { range } from 'lodash-es';
|
||||
|
||||
// The list API clamps any larger limit to 200.
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
export const LIST_ALL_RULES_QUERY_KEY = [...getListRulesV3QueryKey(), 'all'];
|
||||
|
||||
function fetchPage(offset: number): ReturnType<typeof listRulesV3> {
|
||||
return listRulesV3({ limit: PAGE_SIZE, offset });
|
||||
}
|
||||
|
||||
// TODO(AshwinBhatkal): temporary. Replace with server-side search plus a lookup of saved rules by ID once
|
||||
// /api/v3/rules can filter by IDs: https://github.com/SigNoz/pulse-pod/issues/715
|
||||
async function fetchAllRules(): Promise<RuletypesListableRuleDTO[]> {
|
||||
const first = await fetchPage(0);
|
||||
const rest = await Promise.all(
|
||||
range(PAGE_SIZE, first.data.total, PAGE_SIZE).map(fetchPage),
|
||||
);
|
||||
|
||||
return [...first.data.rules, ...rest.flatMap((page) => page.data.rules)];
|
||||
}
|
||||
|
||||
export function useListAllRules(
|
||||
options?: Omit<
|
||||
UseQueryOptions<RuletypesListableRuleDTO[], Error>,
|
||||
'queryKey' | 'queryFn'
|
||||
>,
|
||||
): UseQueryResult<RuletypesListableRuleDTO[], Error> {
|
||||
return useQuery<RuletypesListableRuleDTO[], Error>({
|
||||
queryKey: LIST_ALL_RULES_QUERY_KEY,
|
||||
queryFn: fetchAllRules,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getGetRuleByIDQueryKey,
|
||||
getGetRuleHistoryTimelineQueryOptions,
|
||||
invalidateGetRuleByID,
|
||||
invalidateListRules,
|
||||
invalidateListRulesV3,
|
||||
updateRuleByID,
|
||||
useGetRuleByID,
|
||||
useGetRuleHistoryOverallStatus,
|
||||
@@ -443,7 +443,7 @@ export const useAlertRuleClone = ({
|
||||
message: `Success`,
|
||||
});
|
||||
|
||||
await invalidateListRules(queryClient);
|
||||
await invalidateListRulesV3(queryClient);
|
||||
|
||||
params.set(QueryParams.ruleId, response.data.id);
|
||||
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
@@ -494,7 +494,7 @@ export const useAlertRuleUpdate = ({
|
||||
data: { ...existingRule.data, alert: intermediateName },
|
||||
});
|
||||
}
|
||||
void invalidateListRules(queryClient);
|
||||
void invalidateListRulesV3(queryClient);
|
||||
notifications.success({ message: 'Alert renamed successfully' });
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
@@ -17,10 +17,8 @@ import {
|
||||
FIRST_DOWNTIME_NAME,
|
||||
} from './__story_mockdata__/plannedDowntime';
|
||||
|
||||
import {
|
||||
alertRulesResponse,
|
||||
RULE_MAX,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
import { alertRulesPage } from '../../AlertRules/stories/__story_mockdata__/ruleViews';
|
||||
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
|
||||
import { AlertListSubTabs, AlertListTabs } from '../../types';
|
||||
|
||||
const LIST = 'Planned downtime · list';
|
||||
@@ -86,18 +84,20 @@ export const plannedDowntimeMocks = defineStoryMocks({
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v2/rules', (_req, res, ctx) =>
|
||||
values.rulesState === 'error'
|
||||
? res(ctx.status(500), ctx.json({ status: 'error' }))
|
||||
: res(
|
||||
ctx.json(
|
||||
alertRulesResponse(values.silencedRules, {
|
||||
severity: 'mixed',
|
||||
state: 'mixed',
|
||||
}),
|
||||
),
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
if (values.rulesState === 'error') {
|
||||
return res(ctx.status(500), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.json(
|
||||
alertRulesPage(
|
||||
values.silencedRules,
|
||||
{ severity: 'mixed', state: 'mixed' },
|
||||
req.url.searchParams,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
config: () => ({
|
||||
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.PLANNED_DOWNTIME}`,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { listRulesV3Response } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest } from 'msw';
|
||||
|
||||
import {
|
||||
@@ -156,11 +157,13 @@ export const homeMocks = defineStoryMocks({
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: buildAlertRules(values.alertRules),
|
||||
})),
|
||||
'http://localhost/api/v3/rules',
|
||||
response.json((req) =>
|
||||
listRulesV3Response(
|
||||
buildAlertRules(values.alertRules),
|
||||
req.url.searchParams,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
|
||||
@@ -21,6 +21,20 @@ describe('buildExplorerNavigationUrl', () => {
|
||||
expect(url).toContain(`${QueryParams.viewKey}=`);
|
||||
});
|
||||
|
||||
it('encodes the composite query for a reader that decodes it, even with a percent sign', () => {
|
||||
const query = {
|
||||
queryType: 'builder',
|
||||
filter: { expression: "body LIKE '%timeout%'" },
|
||||
} as never;
|
||||
|
||||
const url = buildExplorerNavigationUrl(ROUTES.LOGS_EXPLORER, query, {});
|
||||
|
||||
const param = new URL(url, window.location.origin).searchParams.get(
|
||||
QueryParams.compositeQuery,
|
||||
) as string;
|
||||
expect(JSON.parse(decodeURIComponent(param))).toStrictEqual(query);
|
||||
});
|
||||
|
||||
it('JSON-encodes panelTypes so the explorer opens the right view', () => {
|
||||
const url = buildExplorerNavigationUrl(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -706,7 +707,9 @@ func TestEmailRejected(t *testing.T) {
|
||||
|
||||
// Send the alert to mock SMTP server.
|
||||
retry, err := e.Notify(context.Background(), firingAlert)
|
||||
require.ErrorContains(t, err, "501 5.5.4 Rejected!")
|
||||
var smtpErr *textproto.Error
|
||||
require.ErrorAs(t, err, &smtpErr)
|
||||
assert.Equal(t, 501, smtpErr.Code)
|
||||
assert.True(t, retry)
|
||||
require.NoError(t, srv.Shutdown(ctx))
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -110,42 +111,44 @@ func TestAggrGroup(t *testing.T) {
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
last = time.Now()
|
||||
current = time.Now()
|
||||
lastCurMtx = &sync.Mutex{}
|
||||
alertsCh = make(chan alertmanagertypes.AlertSlice)
|
||||
)
|
||||
type notification struct {
|
||||
alerts alertmanagertypes.AlertSlice
|
||||
notifiedAt time.Time
|
||||
}
|
||||
alertsCh := make(chan notification)
|
||||
|
||||
ntfy := func(ctx context.Context, alerts ...*alertmanagertypes.Alert) bool {
|
||||
// Validate that the context is properly populated.
|
||||
if _, ok := notify.Now(ctx); !ok {
|
||||
t.Errorf("now missing")
|
||||
notifiedAt, ok := notify.Now(ctx)
|
||||
assert.True(t, ok, "now missing")
|
||||
_, ok = notify.GroupKey(ctx)
|
||||
assert.True(t, ok, "group key missing")
|
||||
lbls, ok := notify.GroupLabels(ctx)
|
||||
if assert.True(t, ok, "group labels missing") {
|
||||
assert.Equal(t, lset, lbls, "wrong group labels")
|
||||
}
|
||||
if _, ok := notify.GroupKey(ctx); !ok {
|
||||
t.Errorf("group key missing")
|
||||
rcv, ok := notify.ReceiverName(ctx)
|
||||
if assert.True(t, ok, "receiver missing") {
|
||||
assert.Equal(t, opts.Receiver, rcv, "wrong receiver")
|
||||
}
|
||||
if lbls, ok := notify.GroupLabels(ctx); !ok || !reflect.DeepEqual(lbls, lset) {
|
||||
t.Errorf("wrong group labels: %q", lbls)
|
||||
}
|
||||
if rcv, ok := notify.ReceiverName(ctx); !ok || rcv != opts.Receiver {
|
||||
t.Errorf("wrong receiver: %q", rcv)
|
||||
}
|
||||
if ri, ok := notify.RepeatInterval(ctx); !ok || ri != notificationConfig.Renotify.RenotifyInterval {
|
||||
t.Errorf("wrong repeat interval: %q", ri)
|
||||
ri, ok := notify.RepeatInterval(ctx)
|
||||
if assert.True(t, ok, "repeat interval missing") {
|
||||
assert.Equal(t, notificationConfig.Renotify.RenotifyInterval, ri, "wrong repeat interval")
|
||||
}
|
||||
|
||||
lastCurMtx.Lock()
|
||||
last = current
|
||||
// Subtract a millisecond to allow for races.
|
||||
current = time.Now().Add(-time.Millisecond)
|
||||
lastCurMtx.Unlock()
|
||||
|
||||
alertsCh <- alertmanagertypes.AlertSlice(alerts)
|
||||
alertsCh <- notification{
|
||||
alerts: alertmanagertypes.AlertSlice(alerts),
|
||||
notifiedAt: notifiedAt,
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
assertNotifiedAfter := func(previous, current time.Time, interval time.Duration) {
|
||||
t.Helper()
|
||||
require.GreaterOrEqual(t, current.Sub(previous), interval, "received batch too early")
|
||||
}
|
||||
|
||||
removeEndsAt := func(as alertmanagertypes.AlertSlice) alertmanagertypes.AlertSlice {
|
||||
for i, a := range as {
|
||||
ac := *a
|
||||
@@ -156,29 +159,26 @@ func TestAggrGroup(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test regular situation where we wait for group_wait to send out alerts.
|
||||
groupStartedAt := time.Now()
|
||||
ag := newAggrGroup(context.Background(), lset, route, nil, promslog.NewNopLogger(), notificationConfig.Renotify.RenotifyInterval)
|
||||
|
||||
go ag.run(ntfy)
|
||||
|
||||
ag.insert(a1)
|
||||
var lastNotificationAt time.Time
|
||||
|
||||
select {
|
||||
case <-time.After(2 * opts.GroupWait):
|
||||
t.Fatalf("expected initial batch after group_wait")
|
||||
require.FailNow(t, "expected initial batch after group_wait")
|
||||
|
||||
case batch := <-alertsCh:
|
||||
lastCurMtx.Lock()
|
||||
s := time.Since(last)
|
||||
lastCurMtx.Unlock()
|
||||
if s < opts.GroupWait {
|
||||
t.Fatalf("received batch too early after %v", s)
|
||||
}
|
||||
case notification := <-alertsCh:
|
||||
assertNotifiedAfter(groupStartedAt, notification.notifiedAt, opts.GroupWait)
|
||||
lastNotificationAt = notification.notifiedAt
|
||||
batch := notification.alerts
|
||||
exp := removeEndsAt(alertmanagertypes.AlertSlice{a1})
|
||||
sort.Sort(batch)
|
||||
|
||||
if !reflect.DeepEqual(batch, exp) {
|
||||
t.Fatalf("expected alerts %v but got %v", exp, batch)
|
||||
}
|
||||
require.Equal(t, exp, batch)
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
@@ -187,21 +187,16 @@ func TestAggrGroup(t *testing.T) {
|
||||
|
||||
select {
|
||||
case <-time.After(2 * opts.GroupInterval):
|
||||
t.Fatalf("expected new batch after group interval but received none")
|
||||
require.FailNow(t, "expected new batch after group interval but received none")
|
||||
|
||||
case batch := <-alertsCh:
|
||||
lastCurMtx.Lock()
|
||||
s := time.Since(last)
|
||||
lastCurMtx.Unlock()
|
||||
if s < opts.GroupInterval {
|
||||
t.Fatalf("received batch too early after %v", s)
|
||||
}
|
||||
case notification := <-alertsCh:
|
||||
assertNotifiedAfter(lastNotificationAt, notification.notifiedAt, opts.GroupInterval)
|
||||
lastNotificationAt = notification.notifiedAt
|
||||
batch := notification.alerts
|
||||
exp := removeEndsAt(alertmanagertypes.AlertSlice{a1, a3})
|
||||
sort.Sort(batch)
|
||||
|
||||
if !reflect.DeepEqual(batch, exp) {
|
||||
t.Fatalf("expected alerts %v but got %v", exp, batch)
|
||||
}
|
||||
require.Equal(t, exp, batch)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,15 +215,15 @@ func TestAggrGroup(t *testing.T) {
|
||||
// a2 lies way in the past so the initial group_wait should be skipped.
|
||||
select {
|
||||
case <-time.After(opts.GroupWait / 2):
|
||||
t.Fatalf("expected immediate alert but received none")
|
||||
require.FailNow(t, "expected immediate alert but received none")
|
||||
|
||||
case batch := <-alertsCh:
|
||||
case notification := <-alertsCh:
|
||||
lastNotificationAt = notification.notifiedAt
|
||||
batch := notification.alerts
|
||||
exp := removeEndsAt(alertmanagertypes.AlertSlice{a1, a2})
|
||||
sort.Sort(batch)
|
||||
|
||||
if !reflect.DeepEqual(batch, exp) {
|
||||
t.Fatalf("expected alerts %v but got %v", exp, batch)
|
||||
}
|
||||
require.Equal(t, exp, batch)
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
@@ -237,21 +232,16 @@ func TestAggrGroup(t *testing.T) {
|
||||
|
||||
select {
|
||||
case <-time.After(2 * opts.GroupInterval):
|
||||
t.Fatalf("expected new batch after group interval but received none")
|
||||
require.FailNow(t, "expected new batch after group interval but received none")
|
||||
|
||||
case batch := <-alertsCh:
|
||||
lastCurMtx.Lock()
|
||||
s := time.Since(last)
|
||||
lastCurMtx.Unlock()
|
||||
if s < opts.GroupInterval {
|
||||
t.Fatalf("received batch too early after %v", s)
|
||||
}
|
||||
case notification := <-alertsCh:
|
||||
assertNotifiedAfter(lastNotificationAt, notification.notifiedAt, opts.GroupInterval)
|
||||
lastNotificationAt = notification.notifiedAt
|
||||
batch := notification.alerts
|
||||
exp := removeEndsAt(alertmanagertypes.AlertSlice{a1, a2, a3})
|
||||
sort.Sort(batch)
|
||||
|
||||
if !reflect.DeepEqual(batch, exp) {
|
||||
t.Fatalf("expected alerts %v but got %v", exp, batch)
|
||||
}
|
||||
require.Equal(t, exp, batch)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,19 +253,14 @@ func TestAggrGroup(t *testing.T) {
|
||||
|
||||
select {
|
||||
case <-time.After(2 * opts.GroupInterval):
|
||||
t.Fatalf("expected new batch after group interval but received none")
|
||||
case batch := <-alertsCh:
|
||||
lastCurMtx.Lock()
|
||||
s := time.Since(last)
|
||||
lastCurMtx.Unlock()
|
||||
if s < opts.GroupInterval {
|
||||
t.Fatalf("received batch too early after %v", s)
|
||||
}
|
||||
require.FailNow(t, "expected new batch after group interval but received none")
|
||||
case notification := <-alertsCh:
|
||||
assertNotifiedAfter(lastNotificationAt, notification.notifiedAt, opts.GroupInterval)
|
||||
lastNotificationAt = notification.notifiedAt
|
||||
batch := notification.alerts
|
||||
sort.Sort(batch)
|
||||
|
||||
if !reflect.DeepEqual(batch, exp) {
|
||||
t.Fatalf("expected alerts %v but got %v", exp, batch)
|
||||
}
|
||||
require.Equal(t, exp, batch)
|
||||
}
|
||||
|
||||
// Resolve all remaining alerts, they should be removed after the next batch was sent.
|
||||
@@ -289,24 +274,16 @@ func TestAggrGroup(t *testing.T) {
|
||||
|
||||
select {
|
||||
case <-time.After(2 * opts.GroupInterval):
|
||||
t.Fatalf("expected new batch after group interval but received none")
|
||||
require.FailNow(t, "expected new batch after group interval but received none")
|
||||
|
||||
case batch := <-alertsCh:
|
||||
lastCurMtx.Lock()
|
||||
s := time.Since(last)
|
||||
lastCurMtx.Unlock()
|
||||
if s < opts.GroupInterval {
|
||||
t.Fatalf("received batch too early after %v", s)
|
||||
}
|
||||
case notification := <-alertsCh:
|
||||
assertNotifiedAfter(lastNotificationAt, notification.notifiedAt, opts.GroupInterval)
|
||||
batch := notification.alerts
|
||||
sort.Sort(batch)
|
||||
|
||||
if !reflect.DeepEqual(batch, resolved) {
|
||||
t.Fatalf("expected alerts %v but got %v", resolved, batch)
|
||||
}
|
||||
require.Equal(t, resolved, batch)
|
||||
|
||||
if !ag.empty() {
|
||||
t.Fatalf("Expected aggregation group to be empty after resolving alerts: %v", ag)
|
||||
}
|
||||
require.Eventually(t, ag.empty, 2*opts.GroupInterval, 10*time.Millisecond, "expected aggregation group to be empty after resolving alerts: %v", ag)
|
||||
}
|
||||
|
||||
ag.stop()
|
||||
|
||||
@@ -30,7 +30,7 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
Description: "Execute a composite query over a time range. Supports builder queries (traces, logs, metrics), formulas, trace operators, PromQL, and ClickHouse SQL.",
|
||||
Request: new(qbtypes.QueryRangeRequest),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: []handler.OpenAPIExample{
|
||||
RequestExamples: append([]handler.OpenAPIExample{
|
||||
{
|
||||
Name: "traces_time_series",
|
||||
Summary: "Time series: count spans grouped by service",
|
||||
@@ -598,7 +598,7 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, queryRangeFunctionExamples()...),
|
||||
Response: new(qbtypes.QueryRangeResponse),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
|
||||
202
pkg/apiserver/signozapiserver/querier_examples.go
Normal file
202
pkg/apiserver/signozapiserver/querier_examples.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package signozapiserver
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/http/handler"
|
||||
|
||||
// timeSeriesExample wraps builder query specs in a one hour time series
|
||||
// request, the shape every function example shares.
|
||||
func timeSeriesExample(name, summary, description string, queries ...map[string]any) handler.OpenAPIExample {
|
||||
list := make([]any, 0, len(queries))
|
||||
for _, query := range queries {
|
||||
list = append(list, query)
|
||||
}
|
||||
return handler.OpenAPIExample{
|
||||
Name: name,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Value: map[string]any{
|
||||
"schemaVersion": "v1",
|
||||
"start": 1640995200000,
|
||||
"end": 1640998800000,
|
||||
"requestType": "time_series",
|
||||
"compositeQuery": map[string]any{"queries": list},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func builderQueryEnvelope(spec map[string]any) map[string]any {
|
||||
return map[string]any{"type": "builder_query", "spec": spec}
|
||||
}
|
||||
|
||||
// function builds a function entry with positional arguments; anomaly is the
|
||||
// one function that takes named arguments and is written out in full.
|
||||
func function(name string, args ...any) map[string]any {
|
||||
list := make([]any, 0, len(args))
|
||||
for _, arg := range args {
|
||||
list = append(list, map[string]any{"value": arg})
|
||||
}
|
||||
entry := map[string]any{"name": name}
|
||||
if len(list) > 0 {
|
||||
entry["args"] = list
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// queryRangeFunctionExamples shows every function a builder query can carry,
|
||||
// what its arguments mean and the values they take. Functions run in the
|
||||
// order listed, after the aggregation and before formulas.
|
||||
func queryRangeFunctionExamples() []handler.OpenAPIExample {
|
||||
return []handler.OpenAPIExample{
|
||||
timeSeriesExample(
|
||||
"function_timeshift_week_over_week",
|
||||
"timeShift: this week's error logs next to last week's",
|
||||
"timeShift moves every point of its query forward by the argument, in seconds, so a series read from the past lands on the current timestamps. B here reads the hour exactly seven days (604800 s) before the requested window and comes back on this week's timestamps, so it overlays A on a chart and the formula can subtract it point by point. The argument is positive; it says how far back to read.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
|
||||
}),
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "B",
|
||||
"signal": "logs",
|
||||
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
|
||||
"functions": []any{function("timeShift", 604800)},
|
||||
}),
|
||||
map[string]any{
|
||||
"type": "builder_formula",
|
||||
"spec": map[string]any{"name": "F1", "expression": "A - B"},
|
||||
},
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_fillzero_sparse_errors",
|
||||
"fillZero: a zero at every step with no failed spans",
|
||||
"A count over sparse events returns a point only for steps that had a match, so a chart of failed spans has gaps where nothing failed. fillZero adds a zero at every missing step. With no arguments the server fills the request window at the query's step, which is the common case. The three optional arguments are start, end and step in milliseconds, for callers that want to fill a different grid.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": []any{map[string]any{"expression": "count()", "alias": "failed_spans"}},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "has_error = true"},
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("fillZero")},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_cutoffmin_hide_idle_hosts",
|
||||
"cutOffMin: drop hosts below 5% CPU from the chart",
|
||||
"The four threshold functions take one number. cutOffMin and cutOffMax remove the points below or above the threshold, so an idle host disappears from the chart instead of drawing a line along the bottom. clampMin and clampMax keep the points and replace the value with the threshold, which caps spikes without hiding them. system.cpu.utilization is a ratio, so 0.05 is 5%.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "system.cpu.utilization", "timeAggregation": "avg", "spaceAggregation": "avg"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"groupBy": []any{map[string]any{"name": "host.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("cutOffMin", 0.05)},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_runningdiff_disk_growth",
|
||||
"runningDiff then clampMin: bytes written to disk per step",
|
||||
"runningDiff turns a gauge into the change between consecutive points, so a filesystem usage gauge becomes bytes written per step. It takes no arguments and drops the first point of the window, since that point has nothing to difference against; the server reads one step before the window to keep the first interval. Functions apply in order: clampMin with 0 runs on the differences and replaces the negative value a cleanup or a volume reset produces with zero.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "system.filesystem.usage", "timeAggregation": "latest", "spaceAggregation": "sum"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "state = 'used'"},
|
||||
"groupBy": []any{map[string]any{"name": "host.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("runningDiff"), function("clampMin", 0)},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_ewma_smoothed_latency",
|
||||
"ewma5: smoothed p95 latency",
|
||||
"ewma3, ewma5 and ewma7 replace each point with an exponentially weighted average of the point and the points before it. The number in the name is the window the default weight is derived from: alpha = 2 / (n + 1), so 0.5, 0.333 and 0.25. One optional argument overrides alpha; a value close to 1 follows the raw series, a value close to 0 smooths harder. 0.3 here smooths a little more than ewma5's default.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "http.server.duration.bucket", "spaceAggregation": "p95"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("ewma5", 0.3)},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_median_despiked_request_rate",
|
||||
"median5: request rate without one-step spikes",
|
||||
"median3, median5 and median7 replace each point with the median of the surrounding 3, 5 or 7 points. A spike that lasts one step vanishes, a shift that lasts longer than half the window stays. The first and last half window, 2 points each for median5, have no full window and keep their values, so the newest spike stays visible; a series shorter than the window is returned unchanged. They take no arguments. Use them when the raw series is noisy and the chart is for a trend, not for catching the spike.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "http.server.duration.count", "timeAggregation": "rate", "spaceAggregation": "sum"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("median5")},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_cumulativesum_errors_so_far",
|
||||
"cumulativeSum: 5xx responses since the start of the window",
|
||||
"cumulativeSum replaces each point with the sum of every point up to and including it, so a per-step count becomes a running total whose first point is the first count. It takes no arguments. The total resets with the window, so a longer time range gives a larger final value; it is a view of the window, not a counter.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": []any{map[string]any{"expression": "count()", "alias": "server_errors"}},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "http.response.status_code >= 500"},
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("cumulativeSum")},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_log10_network_bytes",
|
||||
"log10: network throughput across devices that differ by orders of magnitude",
|
||||
"log2 and log10 replace each value with its logarithm, so a loopback device moving kilobytes and an uplink moving gigabytes fit on one chart. They take no arguments. Zero and negative values have no logarithm and are not drawn. absolute, also without arguments, is the usual companion for a signed series before a logarithm.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "system.network.io", "timeAggregation": "rate", "spaceAggregation": "sum"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "direction = 'transmit'"},
|
||||
"groupBy": []any{map[string]any{"name": "device", "fieldContext": "attribute"}},
|
||||
"functions": []any{function("log10")},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_anomaly_request_rate",
|
||||
"anomaly: request rate scored against its daily pattern",
|
||||
"anomaly is the one function with named arguments. seasonality says which past periods predict the current value: hourly, daily or weekly, daily when absent. z_score_threshold is how many standard deviations from the prediction count as an anomaly. It has no default: a request without it gets bounds of zero width, so send it. 3 is the value the UI sends. The response carries, next to the values, a predicted series, an upper and a lower bound series and an anomaly score series. The scoring runs in the enterprise build; the community build returns the series unchanged.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "http.server.duration.count", "timeAggregation": "rate", "spaceAggregation": "sum"},
|
||||
},
|
||||
"stepInterval": "300s",
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{
|
||||
map[string]any{
|
||||
"name": "anomaly",
|
||||
"args": []any{
|
||||
map[string]any{"name": "seasonality", "value": "daily"},
|
||||
map[string]any{"name": "z_score_threshold", "value": 3},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
23
pkg/apiserver/signozapiserver/querier_examples_test.go
Normal file
23
pkg/apiserver/signozapiserver/querier_examples_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
func TestQueryRangeFunctionExamplesAreValidRequests(t *testing.T) {
|
||||
for _, example := range queryRangeFunctionExamples() {
|
||||
t.Run(example.Name, func(t *testing.T) {
|
||||
raw, err := json.Marshal(example.Value)
|
||||
require.NoError(t, err)
|
||||
var req qbtypes.QueryRangeRequest
|
||||
require.NoError(t, json.Unmarshal(raw, &req))
|
||||
assert.NoError(t, req.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -199,7 +199,8 @@ func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bo
|
||||
|
||||
for _, value := range values {
|
||||
// lookup by number, the type metadata stores numeric attributes under; float64 is only the output cast
|
||||
expr, err := querybuilder.ResolveColumn(ctx, q, s.storage, attributeKey(value.key, telemetrytypes.FieldDataTypeNumber), telemetrytypes.FieldDataTypeFloat64, keys)
|
||||
// fixed gen_ai keys that many traces lack, so a not-found warning means nothing here
|
||||
expr, _, err := querybuilder.ResolveColumn(ctx, q, s.storage, attributeKey(value.key, telemetrytypes.FieldDataTypeNumber), telemetrytypes.FieldDataTypeFloat64, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -915,13 +915,15 @@ func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, q qbtypes
|
||||
// as empty rows that the same request never has when it bypasses the cache.
|
||||
//
|
||||
// PromQL and ClickHouse queries name their own buckets, and a histogram's are
|
||||
// its `le` labels, so only a gauge, sum or summary axis is cut.
|
||||
// its `le` labels, so only a gauge, sum or summary axis and an event axis are cut.
|
||||
func (bc *bucketCache) trimHeatmapAxisToTheWindow(q qbtypes.Query, tsData *qbtypes.TimeSeriesData) {
|
||||
metricQuery, isMetricQuery := q.(*builderQuery[qbtypes.MetricAggregation])
|
||||
if !isMetricQuery {
|
||||
return
|
||||
}
|
||||
if metricQuery.spec.Aggregations[0].Type == metrictypes.HistogramType {
|
||||
switch query := q.(type) {
|
||||
case *builderQuery[qbtypes.MetricAggregation]:
|
||||
if query.spec.Aggregations[0].Type == metrictypes.HistogramType {
|
||||
return
|
||||
}
|
||||
case *builderQuery[qbtypes.LogAggregation], *builderQuery[qbtypes.TraceAggregation]:
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ func newBuilderQuery[T any](
|
||||
func (q *builderQuery[T]) Fingerprint() string {
|
||||
|
||||
if (q.spec.Signal == telemetrytypes.SignalTraces ||
|
||||
q.spec.Signal == telemetrytypes.SignalLogs) && q.kind != qbtypes.RequestTypeTimeSeries {
|
||||
q.spec.Signal == telemetrytypes.SignalLogs) && !q.kind.IsBuiltFromTimeSeries() {
|
||||
// No caching for non-timeseries queries
|
||||
return ""
|
||||
}
|
||||
@@ -88,6 +88,13 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// A limit ranks the groups over the statement window, so the pieces of a
|
||||
// window rank differently from the window. Such a query is never served
|
||||
// from the cache.
|
||||
if (q.spec.Signal == telemetrytypes.SignalLogs || q.spec.Signal == telemetrytypes.SignalTraces) && len(q.spec.GroupBy) > 0 && q.spec.Limit > 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Create a deterministic fingerprint for builder queries
|
||||
// This needs to include all fields that affect the query results
|
||||
parts := []string{q.queryType.StringValue()}
|
||||
@@ -143,6 +150,12 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
parts = append(parts, fmt.Sprintf("aggs=[%s]", strings.Join(aggParts, ",")))
|
||||
}
|
||||
|
||||
// A metrics heatmap carries its axis on the aggregation, which the loop above
|
||||
// already fingerprinted. Logs and traces carry it on the spec.
|
||||
if q.kind.CountsIntoBuckets() && q.spec.Signal != telemetrytypes.SignalMetrics {
|
||||
parts = append(parts, "buckets="+fingerprintHeatmapBucketing(q.spec.BucketOptions.ToHeatmapBucketing()))
|
||||
}
|
||||
|
||||
// Add filter if present
|
||||
if q.spec.Filter != nil && q.spec.Filter.Expression != "" {
|
||||
parts = append(parts, fmt.Sprintf("filter=%s", q.spec.Filter.Expression))
|
||||
|
||||
@@ -191,6 +191,14 @@ func postProcessBuilderQuery[T any](
|
||||
req *qbtypes.QueryRangeRequest,
|
||||
) *qbtypes.Result {
|
||||
|
||||
// A disabled query feeds a formula as a time series, so only an enabled one
|
||||
// came back bucketed.
|
||||
if req.RequestType.CountsIntoBuckets() && !query.Disabled {
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
resolveHeatmapBucketAxis(tsData, query.BucketOptions.ToHeatmapBucketing())
|
||||
}
|
||||
}
|
||||
|
||||
// the statement's top-N already skipped offset groups by their whole-window value; only reorder here
|
||||
result = q.applySeriesLimit(result, query.Limit, 0, query.Order)
|
||||
|
||||
@@ -425,6 +433,12 @@ func (q *querier) processTimeSeriesFormula(
|
||||
return nil
|
||||
}
|
||||
|
||||
formulaSeries, err = applyFormulaHaving(formula, formulaSeries)
|
||||
if err != nil {
|
||||
q.logger.ErrorContext(ctx, "failed to apply formula having", errors.Attr(err), slog.String("formula", formula.Name))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create result for formula
|
||||
formulaResult := &qbtypes.TimeSeriesData{
|
||||
QueryName: formula.Name,
|
||||
@@ -660,6 +674,12 @@ func (q *querier) processScalarFormula(
|
||||
return nil
|
||||
}
|
||||
|
||||
formulaSeries, err = applyFormulaHaving(formula, formulaSeries)
|
||||
if err != nil {
|
||||
q.logger.ErrorContext(ctx, "failed to apply formula having", errors.Attr(err), slog.String("formula", formula.Name))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply ordering (and limit) before converting to scalar format.
|
||||
formulaSeries = qbtypes.ApplySeriesLimit(formulaSeries, formula.Order, formula.Limit, formula.Offset)
|
||||
|
||||
@@ -710,6 +730,35 @@ func (q *querier) processScalarFormula(
|
||||
}
|
||||
}
|
||||
|
||||
// applyFormulaHaving keeps the values the formula having accepts, as SQL HAVING
|
||||
// keeps rows for a builder query, so a series with no values left is removed. It
|
||||
// runs before functions, order, limit, and offset, as HAVING runs before them in SQL.
|
||||
func applyFormulaHaving(formula qbtypes.QueryBuilderFormula, series []*qbtypes.TimeSeries) ([]*qbtypes.TimeSeries, error) {
|
||||
if formula.Having == nil || strings.TrimSpace(formula.Having.Expression) == "" {
|
||||
return series, nil
|
||||
}
|
||||
|
||||
keep, err := querybuilder.CompileFormulaHaving(formula.Name, formula.Having.Expression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
kept := series[:0]
|
||||
for _, s := range series {
|
||||
values := s.Values[:0]
|
||||
for _, value := range s.Values {
|
||||
if keep(value.Value) {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
if len(values) > 0 {
|
||||
s.Values = values
|
||||
kept = append(kept, s)
|
||||
}
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// filterDisabledQueries removes results for disabled queries.
|
||||
func (q *querier) filterDisabledQueries(results map[string]*qbtypes.Result, req *qbtypes.QueryRangeRequest) map[string]*qbtypes.Result {
|
||||
filtered := make(map[string]*qbtypes.Result)
|
||||
|
||||
@@ -170,6 +170,13 @@ func (q *querier) QueryRangePreview(
|
||||
|
||||
q.runPreviewTasks(ctx, previewTasks, results)
|
||||
|
||||
// a filter and a column on the same unknown key report the same warning;
|
||||
// QueryRange dedupes before it builds its response too
|
||||
for name, ps := range results {
|
||||
ps.Warnings = orEmpty(dedupeWarnings(ps.Warnings))
|
||||
results[name] = ps
|
||||
}
|
||||
|
||||
return &qbtypes.QueryRangePreviewResponse{
|
||||
CompositeQuery: results,
|
||||
}, nil
|
||||
|
||||
@@ -212,6 +212,14 @@ func (q *querier) buildQueries(
|
||||
}
|
||||
|
||||
switch query.Type {
|
||||
case qbtypes.QueryTypeFormula:
|
||||
// the querier evaluates a formula after its queries run, so a bad
|
||||
// having must fail here, before they run
|
||||
if formula, ok := query.Spec.(qbtypes.QueryBuilderFormula); ok && formula.Having != nil && strings.TrimSpace(formula.Having.Expression) != "" {
|
||||
if _, err := querybuilder.CompileFormulaHaving(formula.Name, formula.Having.Expression); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
case qbtypes.QueryTypePromQL:
|
||||
promQuery, ok := query.Spec.(qbtypes.PromQuery)
|
||||
if !ok {
|
||||
@@ -249,27 +257,30 @@ func (q *querier) buildQueries(
|
||||
if !ok {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid AI builder query spec %T", query.Spec)
|
||||
}
|
||||
requestType := requestTypeForQuery(req.RequestType, spec.Disabled)
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, builderConfig{})
|
||||
queries[spec.Name] = bq
|
||||
steps[spec.Name] = spec.StepInterval
|
||||
case qbtypes.QueryTypeBuilder:
|
||||
switch spec := query.Spec.(type) {
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
|
||||
requestType := requestTypeForQuery(req.RequestType, spec.Disabled)
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.traceStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.traceStmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, builderConfig{})
|
||||
queries[spec.Name] = bq
|
||||
steps[spec.Name] = spec.StepInterval
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
||||
requestType := requestTypeForQuery(req.RequestType, spec.Disabled)
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
|
||||
stmtBuilder := q.logStmtBuilder
|
||||
if spec.Source == telemetrytypes.SourceAudit {
|
||||
stmtBuilder = q.auditStmtBuilder
|
||||
}
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, stmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, q.builderConfig)
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, stmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, q.builderConfig)
|
||||
queries[spec.Name] = bq
|
||||
steps[spec.Name] = spec.StepInterval
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
|
||||
@@ -280,13 +291,7 @@ func (q *querier) buildQueries(
|
||||
if missingMetricQuerySet[spec.Name] {
|
||||
continue
|
||||
}
|
||||
requestType := req.RequestType
|
||||
if requestType.CountsIntoBuckets() && spec.Disabled {
|
||||
// A disabled query in a heatmap request feeds a formula, and the
|
||||
// formula converts time series into heatmap data, so its inputs
|
||||
// run as time series queries.
|
||||
requestType = qbtypes.RequestTypeTimeSeries
|
||||
}
|
||||
requestType := requestTypeForQuery(req.RequestType, spec.Disabled)
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
|
||||
var bq *builderQuery[qbtypes.MetricAggregation]
|
||||
@@ -922,32 +927,25 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
|
||||
copy(argsCopy, qt.args)
|
||||
return newchSQLQuery(q.logger, q.telemetryStore, queryCopy, argsCopy, timeRange, qt.kind, qt.vars)
|
||||
|
||||
// A builder query's window was moved into the shifted clock when it was
|
||||
// built, so a range taken from that window must not be shifted again.
|
||||
case *builderQuery[qbtypes.TraceAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
// reuse the original query's statement builder and type so an AI query
|
||||
// keeps its AI builder and cache key
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, qt.builderConfig)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, qt.builderConfig)
|
||||
|
||||
case *builderQuery[qbtypes.LogAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
shiftStmtBuilder := q.logStmtBuilder
|
||||
if qt.spec.Source == telemetrytypes.SourceAudit {
|
||||
shiftStmtBuilder = q.auditStmtBuilder
|
||||
}
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, q.builderConfig)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, q.builderConfig)
|
||||
|
||||
case *builderQuery[qbtypes.MetricAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
if qt.spec.Source == telemetrytypes.SourceMeter {
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, builderConfig{})
|
||||
}
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, builderConfig{})
|
||||
case *traceOperatorQuery:
|
||||
specCopy := qt.spec.Copy()
|
||||
return &traceOperatorQuery{
|
||||
@@ -1195,6 +1193,17 @@ func clampStep(qe *qbtypes.QueryEnvelope, recommended, min uint64, warnings *[]s
|
||||
}
|
||||
}
|
||||
|
||||
// requestTypeForQuery downgrades a disabled query in a heatmap request to a
|
||||
// time series one. A disabled query feeds a formula rather than the chart, and
|
||||
// the formula is what turns time series into cells, so its inputs state no
|
||||
// bucket axis of their own.
|
||||
func requestTypeForQuery(requestType qbtypes.RequestType, disabled bool) qbtypes.RequestType {
|
||||
if requestType.CountsIntoBuckets() && disabled {
|
||||
return qbtypes.RequestTypeTimeSeries
|
||||
}
|
||||
return requestType
|
||||
}
|
||||
|
||||
// extractShiftFromBuilderQuery extracts the shift value from timeShift function if present.
|
||||
func extractShiftFromBuilderQuery[T any](spec qbtypes.QueryBuilderQuery[T]) int64 {
|
||||
for _, fn := range spec.Functions {
|
||||
|
||||
@@ -5,17 +5,22 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
chparser "github.com/AfterShip/clickhouse-sql-parser/parser"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/antlr4-go/antlr/v4"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
const argumentPlaceholderPrefix = "__signoz_argument_"
|
||||
|
||||
type aggExprRewriter struct {
|
||||
logger *slog.Logger
|
||||
fullTextColumn *telemetrytypes.TelemetryFieldKey
|
||||
@@ -55,27 +60,28 @@ func (r *aggExprRewriter) Rewrite(
|
||||
expr string,
|
||||
rateInterval uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
) (string, []any, error) {
|
||||
) (string, []any, []string, error) {
|
||||
|
||||
wrapped := fmt.Sprintf("SELECT %s", expr)
|
||||
arguments := map[string]string{}
|
||||
wrapped := fmt.Sprintf("SELECT %s", extractArguments(expr, arguments))
|
||||
p := chparser.NewParser(wrapped)
|
||||
stmts, err := p.ParseStmts()
|
||||
|
||||
if err != nil {
|
||||
return "", nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse aggregation expression %q", expr)
|
||||
return "", nil, nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to parse aggregation expression %q", expr)
|
||||
}
|
||||
|
||||
if len(stmts) == 0 {
|
||||
return "", nil, errors.NewInternalf(errors.CodeInternal, "no statements found for %q", expr)
|
||||
return "", nil, nil, errors.NewInternalf(errors.CodeInternal, "no statements found for %q", expr)
|
||||
}
|
||||
|
||||
sel, ok := stmts[0].(*chparser.SelectQuery)
|
||||
if !ok {
|
||||
return "", nil, errors.NewInternalf(errors.CodeInternal, "expected SelectQuery, got %T", stmts[0])
|
||||
return "", nil, nil, errors.NewInternalf(errors.CodeInternal, "expected SelectQuery, got %T", stmts[0])
|
||||
}
|
||||
|
||||
if len(sel.SelectItems) == 0 {
|
||||
return "", nil, errors.NewInternalf(errors.CodeInternal, "no SELECT items for %q", expr)
|
||||
return "", nil, nil, errors.NewInternalf(errors.CodeInternal, "no SELECT items for %q", expr)
|
||||
}
|
||||
|
||||
visitor := &exprVisitor{
|
||||
@@ -85,16 +91,17 @@ func (r *aggExprRewriter) Rewrite(
|
||||
fieldKeys: keys,
|
||||
fullTextColumn: r.fullTextColumn,
|
||||
storage: r.storage,
|
||||
arguments: arguments,
|
||||
}
|
||||
// Rewrite the first select item (our expression)
|
||||
if err := sel.SelectItems[0].Accept(visitor); err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
if visitor.isRate {
|
||||
return fmt.Sprintf("%s/%d", chparser.Format(sel.SelectItems[0]), rateInterval), visitor.chArgs, nil
|
||||
return fmt.Sprintf("%s/%d", chparser.Format(sel.SelectItems[0]), rateInterval), visitor.chArgs, visitor.warnings, nil
|
||||
}
|
||||
return chparser.Format(sel.SelectItems[0]), visitor.chArgs, nil
|
||||
return chparser.Format(sel.SelectItems[0]), visitor.chArgs, visitor.warnings, nil
|
||||
}
|
||||
|
||||
// RewriteMulti rewrites a slice of expressions.
|
||||
@@ -106,24 +113,26 @@ func (r *aggExprRewriter) RewriteMulti(
|
||||
exprs []string,
|
||||
rateInterval uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
) ([]string, [][]any, error) {
|
||||
) ([]string, [][]any, []string, error) {
|
||||
out := make([]string, len(exprs))
|
||||
var errs []error
|
||||
var chArgsList [][]any
|
||||
var warnings []string
|
||||
for i, e := range exprs {
|
||||
w, chArgs, err := r.Rewrite(ctx, orgID, startNs, endNs, e, rateInterval, keys)
|
||||
w, chArgs, exprWarnings, err := r.Rewrite(ctx, orgID, startNs, endNs, e, rateInterval, keys)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
out[i] = e
|
||||
} else {
|
||||
out[i] = w
|
||||
chArgsList = append(chArgsList, chArgs)
|
||||
warnings = append(warnings, exprWarnings...)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return out, nil, errors.Join(errs...)
|
||||
return out, nil, nil, errors.Join(errs...)
|
||||
}
|
||||
return out, chArgsList, nil
|
||||
return out, chArgsList, warnings, nil
|
||||
}
|
||||
|
||||
// exprVisitor walks FunctionExpr nodes and resolves and renders their
|
||||
@@ -136,8 +145,10 @@ type exprVisitor struct {
|
||||
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey
|
||||
fullTextColumn *telemetrytypes.TelemetryFieldKey
|
||||
storage qbtypes.Storage
|
||||
arguments map[string]string
|
||||
Modified bool
|
||||
chArgs []any
|
||||
warnings []string
|
||||
isRate bool
|
||||
}
|
||||
|
||||
@@ -155,10 +166,16 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
args = fn.Params.Items.Items
|
||||
}
|
||||
|
||||
// count(*) is count()
|
||||
if len(args) == 1 && aggFunc.MinArgs == 0 && v.arguments[chparser.Format(args[0])] == "*" {
|
||||
fn.Params.Items.Items = nil
|
||||
args = nil
|
||||
}
|
||||
|
||||
// if we know aggregation function, we must ensure that the number of arguments is correct
|
||||
if aggFunc.RequireArgs {
|
||||
if len(args) < aggFunc.MinArgs || len(args) > aggFunc.MaxArgs {
|
||||
return errors.NewInternalf(errors.CodeInternal, "invalid number of arguments for %q: %d", name, len(args))
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid number of arguments for %q: %d", name, len(args))
|
||||
}
|
||||
}
|
||||
fn.Name.Name = aggFunc.FuncName
|
||||
@@ -174,7 +191,10 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
// Handle *If functions with predicate + values
|
||||
if aggFunc.FuncCombinator {
|
||||
// Map the predicate (last argument)
|
||||
origPred := chparser.Format(args[len(args)-1])
|
||||
origPred, err := v.argumentText(name, args[len(args)-1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
whereClause, err := PrepareWhereClause(
|
||||
origPred,
|
||||
FilterExprVisitorOpts{
|
||||
@@ -189,6 +209,11 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The search scan budget applies only to the filter.
|
||||
if whereClause.RequiresCostGuard {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "function `search` is not supported inside an aggregation").WithSuggestions("use `search` in the filter")
|
||||
}
|
||||
v.warnings = append(v.warnings, whereClause.Warnings...)
|
||||
// not possible for whereClause to be empty here but still adding a check.
|
||||
if whereClause.IsEmpty() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid predicate argument for %q: %q", name, origPred)
|
||||
@@ -202,16 +227,23 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
}
|
||||
args[len(args)-1] = parsedPred
|
||||
v.Modified = true
|
||||
v.chArgs = chArgs
|
||||
v.chArgs = append(v.chArgs, chArgs...)
|
||||
|
||||
// Map each value column argument
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
origVal := chparser.Format(args[i])
|
||||
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(origVal)
|
||||
expr, err := ResolveColumn(v.ctx, v.query, v.storage, &fieldKey, dataType, v.fieldKeys)
|
||||
origVal, err := v.argumentText(name, args[i])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fieldKey, err := fieldKeyFromArgument(name, origVal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expr, warnings, err := ResolveColumn(v.ctx, v.query, v.storage, &fieldKey, dataType, v.fieldKeys)
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to get table field name for %q", origVal)
|
||||
}
|
||||
v.warnings = append(v.warnings, warnings...)
|
||||
newVal := sqlbuilder.Escape(expr)
|
||||
parsedVal, err := parseFragment(newVal)
|
||||
if err != nil {
|
||||
@@ -223,12 +255,19 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
} else {
|
||||
// Non-If functions: map every argument as a column/value
|
||||
for i, arg := range args {
|
||||
orig := chparser.Format(arg)
|
||||
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(orig)
|
||||
expr, err := ResolveColumn(v.ctx, v.query, v.storage, &fieldKey, dataType, v.fieldKeys)
|
||||
orig, err := v.argumentText(name, arg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fieldKey, err := fieldKeyFromArgument(name, orig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expr, warnings, err := ResolveColumn(v.ctx, v.query, v.storage, &fieldKey, dataType, v.fieldKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v.warnings = append(v.warnings, warnings...)
|
||||
newCol := sqlbuilder.Escape(expr)
|
||||
parsed, err := parseFragment(newCol)
|
||||
if err != nil {
|
||||
@@ -245,6 +284,14 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *exprVisitor) argumentText(name string, arg chparser.Expr) (string, error) {
|
||||
text, ok := v.arguments[chparser.Format(arg)]
|
||||
if !ok {
|
||||
return "", errors.NewInternalf(errors.CodeInternal, "argument of %q was not extracted before parsing", name)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// parseFragment parses a SQL expression fragment by wrapping in SELECT.
|
||||
func parseFragment(sql string) (chparser.Expr, error) {
|
||||
wrapped := fmt.Sprintf("SELECT %s", sql)
|
||||
@@ -262,3 +309,134 @@ func parseFragment(sql string) (chparser.Expr, error) {
|
||||
}
|
||||
return sel.SelectItems[0].Expr, nil
|
||||
}
|
||||
|
||||
// extractArguments replaces every argument of an aggregation call with a
|
||||
// placeholder kept in arguments, because the ClickHouse parser cannot read
|
||||
// the predicates such as EXISTS or CONTAINS, and field keys
|
||||
// with hyphens or slashes. Unbalanced input is returned unchanged for the
|
||||
// parser to reject.
|
||||
func extractArguments(expr string, arguments map[string]string) string {
|
||||
var out strings.Builder
|
||||
written := 0
|
||||
for i := 0; i < len(expr); i++ {
|
||||
c := expr[i]
|
||||
if c == '\'' || c == '"' || c == '`' {
|
||||
end := skipQuoted(expr, i)
|
||||
if end < 0 {
|
||||
return expr
|
||||
}
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
if !isIdentifierStart(c) || (i > 0 && (isIdentifierPart(expr[i-1]) || expr[i-1] == '.')) {
|
||||
continue
|
||||
}
|
||||
nameEnd := i
|
||||
for nameEnd < len(expr) && isIdentifierPart(expr[nameEnd]) {
|
||||
nameEnd++
|
||||
}
|
||||
open := nameEnd
|
||||
for open < len(expr) && strings.ContainsRune(" \t\r\n", rune(expr[open])) {
|
||||
open++
|
||||
}
|
||||
if _, ok := AggreFuncMap[valuer.NewString(strings.ToLower(expr[i:nameEnd]))]; !ok || open == len(expr) || expr[open] != '(' {
|
||||
i = nameEnd - 1
|
||||
continue
|
||||
}
|
||||
commas, closing := scanArguments(expr, open)
|
||||
if closing < 0 {
|
||||
return expr
|
||||
}
|
||||
out.WriteString(expr[written : open+1])
|
||||
bounds := append(append([]int{open}, commas...), closing)
|
||||
for j := 1; j < len(bounds); j++ {
|
||||
if j > 1 {
|
||||
out.WriteString(", ")
|
||||
}
|
||||
text := strings.TrimSpace(expr[bounds[j-1]+1 : bounds[j]])
|
||||
// DISTINCT modifies the whole argument list, so it stays for the parser.
|
||||
if end := strings.IndexFunc(text, unicode.IsSpace); j == 1 && end > 0 && strings.EqualFold(text[:end], "DISTINCT") {
|
||||
out.WriteString("DISTINCT ")
|
||||
text = strings.TrimSpace(text[end:])
|
||||
}
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
placeholder := fmt.Sprintf("%s%d", argumentPlaceholderPrefix, len(arguments))
|
||||
arguments[placeholder] = text
|
||||
out.WriteString(placeholder)
|
||||
}
|
||||
written = closing
|
||||
i = closing
|
||||
}
|
||||
out.WriteString(expr[written:])
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// scanArguments returns the top-level commas and the closing parenthesis of
|
||||
// the argument list that opens at open, with -1 as the closing index when the
|
||||
// list is unterminated.
|
||||
func scanArguments(expr string, open int) ([]int, int) {
|
||||
var commas []int
|
||||
depth := 0
|
||||
for i := open + 1; i < len(expr); i++ {
|
||||
switch expr[i] {
|
||||
case '\'', '"', '`':
|
||||
end := skipQuoted(expr, i)
|
||||
if end < 0 {
|
||||
return nil, -1
|
||||
}
|
||||
i = end
|
||||
case '(', '[':
|
||||
depth++
|
||||
case ']':
|
||||
depth--
|
||||
case ')':
|
||||
if depth == 0 {
|
||||
return commas, i
|
||||
}
|
||||
depth--
|
||||
case ',':
|
||||
if depth == 0 {
|
||||
commas = append(commas, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, -1
|
||||
}
|
||||
|
||||
// fieldKeyFromArgument reads a value argument as a single key of the filter
|
||||
// grammar.
|
||||
func fieldKeyFromArgument(name string, text string) (telemetrytypes.TelemetryFieldKey, error) {
|
||||
lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(text))
|
||||
lexer.RemoveErrorListeners()
|
||||
if lexer.NextToken().GetTokenType() != grammar.FilterQueryLexerKEY || lexer.NextToken().GetTokenType() != antlr.TokenEOF {
|
||||
return telemetrytypes.TelemetryFieldKey{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid argument for %q: %q is not a field name", name, text)
|
||||
}
|
||||
return telemetrytypes.GetFieldKeyFromKeyText(text), nil
|
||||
}
|
||||
|
||||
// skipQuoted returns the index of the quote closing the one at start, honoring
|
||||
// backslash escapes and doubled quotes, or -1 when it is unterminated.
|
||||
func skipQuoted(expr string, start int) int {
|
||||
quote := expr[start]
|
||||
for i := start + 1; i < len(expr); i++ {
|
||||
switch {
|
||||
case expr[i] == '\\':
|
||||
i++
|
||||
case expr[i] == quote && i+1 < len(expr) && expr[i+1] == quote:
|
||||
i++
|
||||
case expr[i] == quote:
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func isIdentifierStart(c byte) bool {
|
||||
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
}
|
||||
|
||||
func isIdentifierPart(c byte) bool {
|
||||
return isIdentifierStart(c) || (c >= '0' && c <= '9')
|
||||
}
|
||||
|
||||
166
pkg/querybuilder/formula_having.go
Normal file
166
pkg/querybuilder/formula_having.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/havingexpression/grammar"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
// CompileFormulaHaving turns the having expression of a formula into a filter on
|
||||
// its values. The querier computes a formula in Go after ClickHouse returns, so the
|
||||
// having cannot run as SQL; it gets the same validation and messages as a
|
||||
// builder-query having, with the formula name and __result as the only references.
|
||||
func CompileFormulaHaving(formulaName, expression string) (func(value float64) bool, error) {
|
||||
rewriter := &HavingExpressionRewriter{columnMap: map[string]string{
|
||||
formulaName: qbtypes.DefaultOrderByKey,
|
||||
qbtypes.DefaultOrderByKey: qbtypes.DefaultOrderByKey,
|
||||
}}
|
||||
_, tree, err := rewriter.parseAndValidate(expression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return compileOrExpression(tree.Expression().OrExpression()), nil
|
||||
}
|
||||
|
||||
func compileOrExpression(ctx grammar.IOrExpressionContext) func(float64) bool {
|
||||
andExpressions := ctx.AllAndExpression()
|
||||
conditions := make([]func(float64) bool, len(andExpressions))
|
||||
for i, andExpression := range andExpressions {
|
||||
conditions[i] = compileAndExpression(andExpression)
|
||||
}
|
||||
|
||||
return func(value float64) bool {
|
||||
for _, condition := range conditions {
|
||||
if condition(value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func compileAndExpression(ctx grammar.IAndExpressionContext) func(float64) bool {
|
||||
primaries := ctx.AllPrimary()
|
||||
conditions := make([]func(float64) bool, len(primaries))
|
||||
for i, primary := range primaries {
|
||||
conditions[i] = compilePrimary(primary)
|
||||
}
|
||||
|
||||
return func(value float64) bool {
|
||||
for _, condition := range conditions {
|
||||
if !condition(value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func compilePrimary(ctx grammar.IPrimaryContext) func(float64) bool {
|
||||
var condition func(float64) bool
|
||||
if ctx.OrExpression() != nil {
|
||||
condition = compileOrExpression(ctx.OrExpression())
|
||||
} else {
|
||||
condition = compileComparison(ctx.Comparison())
|
||||
}
|
||||
|
||||
if ctx.NOT() == nil {
|
||||
return condition
|
||||
}
|
||||
return func(value float64) bool { return !condition(value) }
|
||||
}
|
||||
|
||||
func compileComparison(ctx grammar.IComparisonContext) func(float64) bool {
|
||||
left := compileOperand(ctx.Operand(0))
|
||||
|
||||
if ctx.IN() != nil {
|
||||
signedNumbers := ctx.InList().AllSignedNumber()
|
||||
list := make([]float64, len(signedNumbers))
|
||||
for i, signedNumber := range signedNumbers {
|
||||
list[i] = parseHavingNumber(signedNumber.GetText())
|
||||
}
|
||||
notIn := ctx.NOT() != nil
|
||||
return func(value float64) bool { return slices.Contains(list, left(value)) != notIn }
|
||||
}
|
||||
|
||||
right := compileOperand(ctx.Operand(1))
|
||||
compOp := ctx.CompOp()
|
||||
switch {
|
||||
case compOp.EQUALS() != nil:
|
||||
return func(value float64) bool { return left(value) == right(value) }
|
||||
case compOp.NOT_EQUALS() != nil, compOp.NEQ() != nil:
|
||||
return func(value float64) bool { return left(value) != right(value) }
|
||||
case compOp.LT() != nil:
|
||||
return func(value float64) bool { return left(value) < right(value) }
|
||||
case compOp.LE() != nil:
|
||||
return func(value float64) bool { return left(value) <= right(value) }
|
||||
case compOp.GT() != nil:
|
||||
return func(value float64) bool { return left(value) > right(value) }
|
||||
default:
|
||||
return func(value float64) bool { return left(value) >= right(value) }
|
||||
}
|
||||
}
|
||||
|
||||
func compileOperand(ctx grammar.IOperandContext) func(float64) float64 {
|
||||
if ctx.Operand() == nil {
|
||||
return compileTerm(ctx.Term())
|
||||
}
|
||||
|
||||
left := compileOperand(ctx.Operand())
|
||||
right := compileTerm(ctx.Term())
|
||||
if ctx.MINUS() != nil {
|
||||
return func(value float64) float64 { return left(value) - right(value) }
|
||||
}
|
||||
return func(value float64) float64 { return left(value) + right(value) }
|
||||
}
|
||||
|
||||
func compileTerm(ctx grammar.ITermContext) func(float64) float64 {
|
||||
if ctx.Term() == nil {
|
||||
return compileFactor(ctx.Factor())
|
||||
}
|
||||
|
||||
left := compileTerm(ctx.Term())
|
||||
right := compileFactor(ctx.Factor())
|
||||
switch {
|
||||
case ctx.SLASH() != nil:
|
||||
return func(value float64) float64 { return left(value) / right(value) }
|
||||
case ctx.PERCENT() != nil:
|
||||
return func(value float64) float64 { return math.Mod(left(value), right(value)) }
|
||||
default:
|
||||
return func(value float64) float64 { return left(value) * right(value) }
|
||||
}
|
||||
}
|
||||
|
||||
func compileFactor(ctx grammar.IFactorContext) func(float64) float64 {
|
||||
if ctx.Factor() != nil {
|
||||
inner := compileFactor(ctx.Factor())
|
||||
if ctx.MINUS() != nil {
|
||||
return func(value float64) float64 { return -inner(value) }
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
if ctx.Operand() != nil {
|
||||
return compileOperand(ctx.Operand())
|
||||
}
|
||||
|
||||
if number := ctx.Atom().NUMBER(); number != nil {
|
||||
constant := parseHavingNumber(number.GetText())
|
||||
return func(float64) float64 { return constant }
|
||||
}
|
||||
|
||||
// validation rejects function calls, strings, and unknown names, so the atom
|
||||
// names the formula value
|
||||
return func(value float64) float64 { return value }
|
||||
}
|
||||
|
||||
// ParseFloat reads every form the lexer admits as a number; a literal outside the
|
||||
// float64 range becomes the nearest value ParseFloat returns.
|
||||
func parseHavingNumber(text string) float64 {
|
||||
number, _ := strconv.ParseFloat(text, 64)
|
||||
return number
|
||||
}
|
||||
86
pkg/querybuilder/formula_having_test.go
Normal file
86
pkg/querybuilder/formula_having_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
func TestCompileFormulaHaving_Matches(t *testing.T) {
|
||||
values := []float64{-2, 0, 1, 2.5, 3, 10}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
expression string
|
||||
want []float64
|
||||
}{
|
||||
{name: "Name_GreaterThan", expression: "F1 > 2", want: []float64{2.5, 3, 10}},
|
||||
{name: "Result_LessOrEqual", expression: "__result <= 1", want: []float64{-2, 0, 1}},
|
||||
{name: "DoubleEquals", expression: "F1 == 3", want: []float64{3}},
|
||||
{name: "AngleNotEquals", expression: "F1 <> 0", want: []float64{-2, 1, 2.5, 3, 10}},
|
||||
{name: "In_Parentheses", expression: "F1 IN (0, 3, -2)", want: []float64{-2, 0, 3}},
|
||||
{name: "NotIn_Brackets", expression: "F1 NOT IN [0, 3, -2]", want: []float64{1, 2.5, 10}},
|
||||
{name: "AndBindsTighterThanOr", expression: "F1 > 0 AND F1 < 3 OR F1 = -2", want: []float64{-2, 1, 2.5}},
|
||||
{name: "ImplicitAnd", expression: "F1 > 0 F1 < 3", want: []float64{1, 2.5}},
|
||||
{name: "LowercaseKeywords", expression: "F1 > 2 and not F1 = 10", want: []float64{2.5, 3}},
|
||||
{name: "NotParenthesized", expression: "NOT (F1 >= 1 AND F1 <= 3)", want: []float64{-2, 0, 10}},
|
||||
{name: "Arithmetic", expression: "F1 * 2 - 1 > 4", want: []float64{3, 10}},
|
||||
{name: "Arithmetic_Parentheses", expression: "(F1 + 2) / 2 >= 2", want: []float64{2.5, 3, 10}},
|
||||
{name: "Modulo", expression: "F1 % 2 = 1", want: []float64{1, 3}},
|
||||
{name: "UnaryMinus", expression: "-F1 > 1", want: []float64{-2}},
|
||||
{name: "Number_Exponent", expression: "F1 >= 1e1", want: []float64{10}},
|
||||
{name: "Number_LeadingDot", expression: "F1 < .5", want: []float64{-2, 0}},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
keep, err := CompileFormulaHaving("F1", testCase.expression)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, keep)
|
||||
|
||||
got := []float64{}
|
||||
for _, value := range values {
|
||||
if keep(value) {
|
||||
got = append(got, value)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, testCase.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileFormulaHaving_Invalid(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
expression string
|
||||
wantErrMsg string
|
||||
wantAdditional []string
|
||||
}{
|
||||
{
|
||||
name: "QueryName_NotAReference",
|
||||
expression: "A > 0",
|
||||
wantErrMsg: "Invalid references in `Having` expression: [A]",
|
||||
wantAdditional: []string{"Valid references are: [F1, __result]"},
|
||||
},
|
||||
{
|
||||
name: "Aggregation_NotAReference",
|
||||
expression: "count() > 0",
|
||||
wantErrMsg: "Invalid references in `Having` expression: [count]",
|
||||
wantAdditional: []string{"Valid references are: [F1, __result]"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
keep, err := CompileFormulaHaving("F1", testCase.expression)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, keep)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
assert.ErrorContains(t, err, testCase.wantErrMsg)
|
||||
assert.Equal(t, testCase.wantAdditional, additionalMessages(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -237,7 +237,13 @@ func (v *havingExpressionRewriteVisitor) visitIdentifier(ctx grammar.IIdentifier
|
||||
return name
|
||||
}
|
||||
|
||||
// rewriteAndValidate is the single-pass implementation used by all RewriteFor* methods.
|
||||
func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string, error) {
|
||||
rewritten, _, err := r.parseAndValidate(expression)
|
||||
return rewritten, err
|
||||
}
|
||||
|
||||
// parseAndValidate is the single-pass implementation used by all RewriteFor* methods.
|
||||
// It also returns the parse tree, which CompileFormulaHaving evaluates in Go.
|
||||
//
|
||||
// Validation layers:
|
||||
// 1. The visitor runs on the parse tree, rewriting and collecting invalid references.
|
||||
@@ -248,7 +254,7 @@ func (v *havingExpressionRewriteVisitor) visitIdentifier(ctx grammar.IIdentifier
|
||||
// 2. String literals in comparison-operand position → descriptive error
|
||||
// ("aggregator results are numeric").
|
||||
// 3. ANTLR syntax errors → error with messages referencing the original token names.
|
||||
func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string, error) {
|
||||
func (r *HavingExpressionRewriter) parseAndValidate(expression string) (string, grammar.IQueryContext, error) {
|
||||
original := strings.TrimSpace(expression)
|
||||
|
||||
// Parse the expression once.
|
||||
@@ -281,7 +287,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
// This is checked before invalid references so that "contains string literals" takes
|
||||
// priority when a bare string literal is also an unresolvable operand.
|
||||
if v.hasStringLiteral {
|
||||
return "", errors.NewInvalidInputf(
|
||||
return "", nil, errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"`Having` expression contains string literals",
|
||||
).WithAdditional("Aggregator results are numeric")
|
||||
@@ -317,7 +323,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
).WithAdditional(
|
||||
"Valid references are: [" + strings.Join(validKeys, ", ") + "]",
|
||||
).WithSuggestions(suggestions...)
|
||||
return "", havingErr
|
||||
return "", nil, havingErr
|
||||
}
|
||||
|
||||
// Layer 3 – ANTLR syntax errors. We parse the original expression, so error messages
|
||||
@@ -343,12 +349,12 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
return havingSuggestion(allSyntaxErrors[0], original)
|
||||
})
|
||||
|
||||
return "", havingErr.WithSuggestiveAdditional(msgs[0], suggestions...)
|
||||
return "", nil, havingErr.WithSuggestiveAdditional(msgs[0], suggestions...)
|
||||
}
|
||||
return "", havingErr.WithAdditional(msgs...)
|
||||
return "", nil, havingErr.WithAdditional(msgs...)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return result, tree, nil
|
||||
}
|
||||
|
||||
// havingSuggestion returns a corrected expression string to show as a suggestion when
|
||||
|
||||
136
pkg/querybuilder/heatmap.go
Normal file
136
pkg/querybuilder/heatmap.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// heatmapBucketValueAlias names the value a heatmap's bounds are cut from. The
|
||||
// bounds read it several times, so it is declared once as a WITH expression
|
||||
// alias rather than repeated.
|
||||
const heatmapBucketValueAlias = "__bucket_value"
|
||||
|
||||
// RenderHeatmapBucketExprs renders the bucket (min, max] that valueExpr falls
|
||||
// in. Only the bucket under everything the axis covers is open below, and only
|
||||
// the one over it is open above.
|
||||
//
|
||||
// valueExpr is repeated several times in each expression, so it has to be free
|
||||
// of placeholders — HeatmapBucketColumns passes an alias for that reason.
|
||||
func RenderHeatmapBucketExprs(bucketing qbtypes.HeatmapBucketing, valueExpr string) (minExpr, maxExpr string) {
|
||||
if bucketing.Kind == qbtypes.BucketsKindLinear {
|
||||
return renderLinearBucketExprs(bucketing, valueExpr)
|
||||
}
|
||||
return renderLogBucketExprs(valueExpr)
|
||||
}
|
||||
|
||||
func renderLinearBucketExprs(bucketing qbtypes.HeatmapBucketing, valueExpr string) (string, string) {
|
||||
maxValue := FormatHeatmapFloat(bucketing.MaxValue)
|
||||
numBuckets := strconv.Itoa(bucketing.NumBuckets)
|
||||
index := fmt.Sprintf("least(greatest(ceil(%s * %s / %s), 1), %s)", valueExpr, numBuckets, maxValue, numBuckets)
|
||||
|
||||
minExpr := fmt.Sprintf(
|
||||
"multiIf(%s <= 0, toFloat64('-Inf'), %s > %s, toFloat64(%s), (%s - 1) * %s / %s)",
|
||||
valueExpr, valueExpr, maxValue, maxValue, index, maxValue, numBuckets,
|
||||
)
|
||||
maxExpr := fmt.Sprintf(
|
||||
"multiIf(%s <= 0, toFloat64(0), %s > %s, toFloat64('+Inf'), %s * %s / %s)",
|
||||
valueExpr, valueExpr, maxValue, index, maxValue, numBuckets,
|
||||
)
|
||||
return minExpr, maxExpr
|
||||
}
|
||||
|
||||
// ClickHouse buckets at MaxLogScale whatever HeatmapBucketing.LogScale asks for;
|
||||
// postprocessing folds the axis down afterwards.
|
||||
func renderLogBucketExprs(valueExpr string) (string, string) {
|
||||
bucketsPerDoubling := FormatHeatmapFloat(math.Exp2(qbtypes.MaxLogScale))
|
||||
lowest := FormatHeatmapFloat(qbtypes.MinLogUpperBound)
|
||||
highest := FormatHeatmapFloat(qbtypes.MaxLogUpperBound)
|
||||
|
||||
minExpr := fmt.Sprintf(
|
||||
"multiIf(%s <= 0, toFloat64('-Inf'), %s <= %s, toFloat64(0), %s > %s, toFloat64(%s), pow(2, (ceil(log2(%s) * %s) - 1) / %s))",
|
||||
valueExpr, valueExpr, lowest, valueExpr, highest, highest, valueExpr, bucketsPerDoubling, bucketsPerDoubling,
|
||||
)
|
||||
maxExpr := fmt.Sprintf(
|
||||
"multiIf(%s <= 0, toFloat64(0), %s <= %s, %s, %s > %s, toFloat64('+Inf'), pow(2, ceil(log2(%s) * %s) / %s))",
|
||||
valueExpr, valueExpr, lowest, lowest, valueExpr, highest, valueExpr, bucketsPerDoubling, bucketsPerDoubling,
|
||||
)
|
||||
return minExpr, maxExpr
|
||||
}
|
||||
|
||||
// FormatHeatmapFloat renders a float64 as the shortest literal that reads back
|
||||
// as the same value, so an upper bound computed from it is identical on every
|
||||
// row.
|
||||
func FormatHeatmapFloat(v float64) string {
|
||||
return strconv.FormatFloat(v, 'g', -1, 64)
|
||||
}
|
||||
|
||||
// HeatmapFieldMayBeAbsent reports whether some row can lack key. A key that
|
||||
// resolves to a table column every row carries, such as a span's duration,
|
||||
// cannot, as the column is read first, and an EXISTS on it would drop its
|
||||
// zeros instead.
|
||||
func HeatmapFieldMayBeAbsent(
|
||||
ctx context.Context,
|
||||
info qbtypes.QueryInfo,
|
||||
storage qbtypes.Storage,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
) (bool, error) {
|
||||
resolved, err := Resolve(ctx, info, storage, key, qbtypes.FilterOperatorUnknown, nil, keys)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, field := range resolved.Fields {
|
||||
read, err := LogicalRead(ctx, info, storage, field)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if read.WhenAbsent == qbtypes.AlwaysPresent {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// HeatmapBucketColumns adds the bucket a row's value falls in to sb's select
|
||||
// list, as the (min, max] pair a heatmap statement reports. key is the field
|
||||
// the axis is cut from, already adjusted for context and data type.
|
||||
//
|
||||
// The bounds read the value through an expression alias rather than repeating
|
||||
// it, so the returned fragment has to be declared alongside the caller's CTEs.
|
||||
// It carries no arguments of its own.
|
||||
//
|
||||
// A row without a numeric value counts as 0.
|
||||
func HeatmapBucketColumns(
|
||||
ctx context.Context,
|
||||
info qbtypes.QueryInfo,
|
||||
storage qbtypes.Storage,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
bucketOptions *qbtypes.BucketOptions,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
) (valueFragment string, groupByAliases []string, warnings []string, err error) {
|
||||
// Resolved directly rather than through ResolveColumn: a key nothing carries
|
||||
// puts every row at 0, and the warning is the only thing that says why.
|
||||
resolved, err := Resolve(ctx, info, storage, key, qbtypes.FilterOperatorUnknown, nil, keys)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
valueExpr, err := Column(ctx, info, storage, resolved, telemetrytypes.FieldDataTypeFloat64)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
bucketMin, bucketMax := RenderHeatmapBucketExprs(bucketOptions.ToHeatmapBucketing(), heatmapBucketValueAlias)
|
||||
|
||||
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", bucketMin, qbtypes.HeatmapBucketMinColumn)))
|
||||
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", bucketMax, qbtypes.HeatmapBucketMaxColumn)))
|
||||
|
||||
valueFragment = fmt.Sprintf("ifNull(%s, 0) AS %s", valueExpr, heatmapBucketValueAlias)
|
||||
return valueFragment, []string{qbtypes.HeatmapBucketMinColumn, qbtypes.HeatmapBucketMaxColumn}, resolved.Warnings, nil
|
||||
}
|
||||
@@ -111,7 +111,8 @@ func Resolve(
|
||||
}
|
||||
|
||||
// ResolveColumn resolves one key for a select field, group by, order by, or
|
||||
// aggregation, and renders its column expression.
|
||||
// aggregation, and renders its column expression. The warnings are the ones
|
||||
// a filter on the same key reports.
|
||||
func ResolveColumn(
|
||||
ctx context.Context,
|
||||
q qbtypes.QueryInfo,
|
||||
@@ -119,12 +120,16 @@ func ResolveColumn(
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
target telemetrytypes.FieldDataType,
|
||||
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
) (string, error) {
|
||||
) (string, []string, error) {
|
||||
resolved, err := Resolve(ctx, q, storage, key, qbtypes.FilterOperatorUnknown, nil, fieldKeys)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", nil, err
|
||||
}
|
||||
return Column(ctx, q, storage, resolved, target)
|
||||
column, err := Column(ctx, q, storage, resolved, target)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return column, resolved.Warnings, nil
|
||||
}
|
||||
|
||||
// intrinsicColumnFirst puts a column every row has first for a bare key.
|
||||
|
||||
86
pkg/querybuilder/resolve_test.go
Normal file
86
pkg/querybuilder/resolve_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package querybuilder_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/audittelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A key metadata does not report warns and the query runs, so in a select
|
||||
// field, group by, order by, or aggregation every storage's fallback must read.
|
||||
// The only failure allowed is invalid input for a context the signal does not
|
||||
// serve.
|
||||
func TestResolveColumn_UnknownKey(t *testing.T) {
|
||||
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||
start := uint64(releaseTime.Add(24 * time.Hour).UnixNano())
|
||||
end := start + uint64(time.Hour.Nanoseconds())
|
||||
|
||||
schemaMetadataStore := telemetrytypestest.NewMockSchemaMetadataStore(t)
|
||||
schemaMetadataStore.EXPECT().GetBaseColumnEvolutions(mock.Anything, telemetrytypes.SignalTraces, telemetrytypes.FieldContextAttribute).Return(tracestelemetryschema.MockAttributeEvolutionData(releaseTime), nil).Maybe()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
storage qbtypes.Storage
|
||||
query qbtypes.QueryInfo
|
||||
}{
|
||||
{
|
||||
name: "Logs",
|
||||
storage: logstelemetryschema.NewStorage(),
|
||||
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalLogs, StartNs: start, EndNs: end},
|
||||
},
|
||||
{
|
||||
name: "Logs_JSONBody",
|
||||
storage: logstelemetryschema.NewStorage(),
|
||||
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalLogs, StartNs: start, EndNs: end, BodyJSONOn: true},
|
||||
},
|
||||
{
|
||||
name: "Traces",
|
||||
storage: tracestelemetryschema.NewStorage(schemaMetadataStore),
|
||||
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalTraces, StartNs: start, EndNs: end},
|
||||
},
|
||||
{
|
||||
name: "Traces_JSONAttributes",
|
||||
storage: tracestelemetryschema.NewStorage(schemaMetadataStore),
|
||||
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalTraces, StartNs: start, EndNs: end, TraceAttrsJSONOn: true},
|
||||
},
|
||||
{
|
||||
name: "Metrics",
|
||||
storage: metricstelemetryschema.NewStorage(),
|
||||
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalMetrics, StartNs: start, EndNs: end},
|
||||
},
|
||||
{
|
||||
name: "Audit",
|
||||
storage: audittelemetryschema.NewStorage(),
|
||||
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalLogs, StartNs: start, EndNs: end},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
for _, value := range (telemetrytypes.FieldContext{}).Enum() {
|
||||
fieldContext, ok := value.(telemetrytypes.FieldContext)
|
||||
require.True(t, ok)
|
||||
|
||||
t.Run(testCase.name+"_"+fieldContext.StringValue(), func(t *testing.T) {
|
||||
key := telemetrytypes.TelemetryFieldKey{Name: "unknown.key", FieldContext: fieldContext, Signal: testCase.query.Signal}
|
||||
|
||||
_, _, err := querybuilder.ResolveColumn(context.Background(), testCase.query, testCase.storage, &key, telemetrytypes.FieldDataTypeUnspecified, map[string][]*telemetrytypes.TelemetryFieldKey{})
|
||||
if err != nil {
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput), "%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,8 @@ func (b *auditQueryStatementBuilder) Build(
|
||||
stmt, err = b.buildListQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
stmt, err = b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeHeatmap, qbtypes.RequestTypeDistribution:
|
||||
stmt, err = b.buildHeatmapTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
stmt, err = b.buildScalarQuery(ctx, orgID, q, query, start, end, keys, false, variables)
|
||||
default:
|
||||
@@ -229,8 +231,9 @@ func (b *auditQueryStatementBuilder) buildListQuery(
|
||||
) (*qbtypes.Statement, error) {
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
|
||||
var (
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
columnWarnings []string
|
||||
)
|
||||
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables); err != nil {
|
||||
@@ -263,10 +266,11 @@ func (b *auditQueryStatementBuilder) buildListQuery(
|
||||
continue
|
||||
}
|
||||
|
||||
colExpr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &query.SelectFields[index], telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
colExpr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &query.SelectFields[index], telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
sb.SelectMore(fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(colExpr), query.SelectFields[index].Name))
|
||||
}
|
||||
}
|
||||
@@ -279,10 +283,11 @@ func (b *auditQueryStatementBuilder) buildListQuery(
|
||||
}
|
||||
|
||||
for _, orderBy := range query.Order {
|
||||
colExpr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
colExpr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
sb.OrderBy(fmt.Sprintf("%s %s", sqlbuilder.Escape(colExpr), orderBy.Direction.StringValue()))
|
||||
}
|
||||
|
||||
@@ -304,7 +309,7 @@ func (b *auditQueryStatementBuilder) buildListQuery(
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
}
|
||||
|
||||
@@ -319,10 +324,53 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, query, start, end, keys, variables, nil)
|
||||
}
|
||||
|
||||
// buildHeatmapTimeSeriesQuery counts rows per (time bucket, group, value
|
||||
// bucket) of the field its aggregation names.
|
||||
func (b *auditQueryStatementBuilder) buildHeatmapTimeSeriesQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
heatmapField, countQuery, err := qbtypes.HeatmapCountQuery(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
|
||||
mayBeAbsent, err := querybuilder.HeatmapFieldMayBeAbsent(ctx, info, b.storage, heatmapField, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mayBeAbsent {
|
||||
countQuery = qbtypes.RequireHeatmapField(countQuery, *heatmapField)
|
||||
}
|
||||
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, countQuery, start, end, keys, variables, heatmapField)
|
||||
}
|
||||
|
||||
// buildTimeSeriesOrHeatmapQuery builds one row per (time bucket, group). A
|
||||
// non-nil heatmapField makes it a heatmap instead: the bucket a row's
|
||||
// heatmapField falls in joins the group keys, so a row is one cell.
|
||||
func (b *auditQueryStatementBuilder) buildTimeSeriesOrHeatmapQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
heatmapField *telemetrytypes.TelemetryFieldKey,
|
||||
) (*qbtypes.Statement, error) {
|
||||
var (
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
columnWarnings []string
|
||||
)
|
||||
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables); err != nil {
|
||||
@@ -342,22 +390,35 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
fieldNames := make([]string, 0, len(query.GroupBy))
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
|
||||
for _, gb := range query.GroupBy {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
|
||||
colExpr := sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, clickhousesql.Identifier(gb.Name)))
|
||||
sb.SelectMore(colExpr)
|
||||
fieldNames = append(fieldNames, sqlbuilder.Escape(clickhousesql.Identifier(gb.Name)))
|
||||
}
|
||||
|
||||
allAggChArgs := make([]any, 0)
|
||||
for i, agg := range query.Aggregations {
|
||||
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, orgID, start, end, agg.Expression, uint64(query.StepInterval.Seconds()), keys)
|
||||
var bucketAliases []string
|
||||
if heatmapField != nil {
|
||||
valueFragment, aliases, warnings, err := querybuilder.HeatmapBucketColumns(ctx, info, b.storage, sb, heatmapField, query.BucketOptions, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cteFragments = append(cteFragments, valueFragment)
|
||||
bucketAliases = aliases
|
||||
columnWarnings = append(columnWarnings, warnings...)
|
||||
}
|
||||
|
||||
allAggChArgs := make([]any, 0)
|
||||
for i, agg := range query.Aggregations {
|
||||
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(ctx, orgID, start, end, agg.Expression, uint64(query.StepInterval.Seconds()), keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, aggWarnings...)
|
||||
allAggChArgs = append(allAggChArgs, chArgs...)
|
||||
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, i))
|
||||
}
|
||||
@@ -387,6 +448,7 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
|
||||
sb.GroupBy("ts")
|
||||
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
|
||||
sb.GroupBy(bucketAliases...)
|
||||
if query.Having != nil && query.Having.Expression != "" {
|
||||
rewriter := querybuilder.NewHavingExpressionRewriter()
|
||||
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
|
||||
@@ -414,6 +476,7 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
} else {
|
||||
sb.GroupBy("ts")
|
||||
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
|
||||
sb.GroupBy(bucketAliases...)
|
||||
if query.Having != nil && query.Having.Expression != "" {
|
||||
rewriter := querybuilder.NewHavingExpressionRewriter()
|
||||
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
|
||||
@@ -443,7 +506,7 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
}
|
||||
|
||||
@@ -461,8 +524,9 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
var (
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
columnWarnings []string
|
||||
)
|
||||
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables); err != nil {
|
||||
@@ -478,10 +542,11 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
|
||||
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
|
||||
for _, gb := range query.GroupBy {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
|
||||
colExpr := sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, clickhousesql.Identifier(gb.Name)))
|
||||
sb.SelectMore(colExpr)
|
||||
@@ -492,10 +557,11 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
|
||||
if len(query.Aggregations) > 0 {
|
||||
for idx := range query.Aggregations {
|
||||
aggExpr := query.Aggregations[idx]
|
||||
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, orgID, start, end, aggExpr.Expression, rateInterval, keys)
|
||||
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(ctx, orgID, start, end, aggExpr.Expression, rateInterval, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, aggWarnings...)
|
||||
allAggChArgs = append(allAggChArgs, chArgs...)
|
||||
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, idx))
|
||||
}
|
||||
@@ -550,7 +616,7 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -224,3 +225,59 @@ func TestStatementBuilder(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatementBuilderHeatmap(t *testing.T) {
|
||||
statementBuilder := newTestAuditStatementBuilder(t)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
expected qbtypes.Statement
|
||||
}{
|
||||
{
|
||||
name: "AttributeField_GroupByMaterialized_LinearAxis_RequiresField",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Source: telemetrytypes.SourceAudit,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "http.response.status_code"}},
|
||||
Filter: &qbtypes.Filter{Expression: "signoz.audit.resource.kind = 'dashboard'"},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.audit.action"}}},
|
||||
BucketOptions: &qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLinear, Spec: qbtypes.LinearBucketsSpec{MaxValue: 500, NumBuckets: 5}},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'http.response.status_code'), toFloat64(attributes_number['http.response.status_code']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$action_exists` = true, `attribute_string_signoz$$audit$$action`, NULL)) AS `signoz.audit.action`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 500, toFloat64(500), (least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) - 1) * 500 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 500, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) * 500 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'http.response.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, `signoz.audit.action`, __bucket_min, __bucket_max",
|
||||
Args: []any{"dashboard", "%signoz.audit.resource.kind%", "%signoz.audit.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Distribution_AttributeField_SameStatementAsHeatmap",
|
||||
requestType: qbtypes.RequestTypeDistribution,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Source: telemetrytypes.SourceAudit,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "http.response.status_code"}},
|
||||
Filter: &qbtypes.Filter{Expression: "signoz.audit.resource.kind = 'dashboard'"},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.audit.action"}}},
|
||||
BucketOptions: &qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLinear, Spec: qbtypes.LinearBucketsSpec{MaxValue: 500, NumBuckets: 5}},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'http.response.status_code'), toFloat64(attributes_number['http.response.status_code']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$action_exists` = true, `attribute_string_signoz$$audit$$action`, NULL)) AS `signoz.audit.action`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 500, toFloat64(500), (least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) - 1) * 500 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 500, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) * 500 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'http.response.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, `signoz.audit.action`, __bucket_min, __bucket_max",
|
||||
Args: []any{"dashboard", "%signoz.audit.resource.kind%", "%signoz.audit.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
statement, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, testCase.requestType, testCase.query, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expected.Query, statement.Query)
|
||||
assert.Equal(t, testCase.expected.Args, statement.Args)
|
||||
assert.Equal(t, testCase.expected.Warnings, statement.Warnings)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,8 @@ func (b *logQueryStatementBuilder) Build(
|
||||
stmt, err = b.buildListQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
stmt, err = b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeHeatmap, qbtypes.RequestTypeDistribution:
|
||||
stmt, err = b.buildHeatmapTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
stmt, err = b.buildScalarQuery(ctx, orgID, q, query, start, end, keys, false, variables)
|
||||
default:
|
||||
@@ -337,8 +339,9 @@ func (b *logQueryStatementBuilder) buildListQuery(
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
|
||||
|
||||
var (
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
columnWarnings []string
|
||||
)
|
||||
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
|
||||
@@ -377,10 +380,11 @@ func (b *logQueryStatementBuilder) buildListQuery(
|
||||
}
|
||||
|
||||
// get column expression for the field - use array index directly to avoid pointer to loop variable
|
||||
colExpr, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&query.SelectFields[index]), telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
colExpr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&query.SelectFields[index]), telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", colExpr, selectColumnAlias(index, query.SelectFields[index].Name))))
|
||||
}
|
||||
}
|
||||
@@ -396,10 +400,11 @@ func (b *logQueryStatementBuilder) buildListQuery(
|
||||
// Add order by
|
||||
for _, orderBy := range query.Order {
|
||||
|
||||
colExpr, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&orderBy.Key.TelemetryFieldKey), telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
colExpr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&orderBy.Key.TelemetryFieldKey), telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
sb.OrderBy(fmt.Sprintf("%s %s", sqlbuilder.Escape(colExpr), orderBy.Direction.StringValue()))
|
||||
}
|
||||
|
||||
@@ -422,7 +427,7 @@ func (b *logQueryStatementBuilder) buildListQuery(
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
CostGuard: b.costGuardFor(ctx, orgID, preparedWhereClause.RequiresCostGuard),
|
||||
}
|
||||
@@ -439,10 +444,53 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, query, start, end, keys, variables, nil)
|
||||
}
|
||||
|
||||
// buildHeatmapTimeSeriesQuery counts rows per (time bucket, group, value
|
||||
// bucket) of the field its aggregation names.
|
||||
func (b *logQueryStatementBuilder) buildHeatmapTimeSeriesQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
heatmapField, countQuery, err := qbtypes.HeatmapCountQuery(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
|
||||
mayBeAbsent, err := querybuilder.HeatmapFieldMayBeAbsent(ctx, info, b.storage, columnKey(heatmapField), keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mayBeAbsent {
|
||||
countQuery = qbtypes.RequireHeatmapField(countQuery, *heatmapField)
|
||||
}
|
||||
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, countQuery, start, end, keys, variables, heatmapField)
|
||||
}
|
||||
|
||||
// buildTimeSeriesOrHeatmapQuery builds one row per (time bucket, group). A
|
||||
// non-nil heatmapField makes it a heatmap instead: the bucket a row's
|
||||
// heatmapField falls in joins the group keys, so a row is one cell.
|
||||
func (b *logQueryStatementBuilder) buildTimeSeriesOrHeatmapQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
heatmapField *telemetrytypes.TelemetryFieldKey,
|
||||
) (*qbtypes.Statement, error) {
|
||||
|
||||
var (
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
columnWarnings []string
|
||||
)
|
||||
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
|
||||
@@ -466,20 +514,34 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
if !info.BodyJSONOn && (strings.Contains(gb.Name, telemetrytypes.ArraySep) || strings.Contains(gb.Name, telemetrytypes.ArrayAnyIndex)) {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "Group by/Aggregation isn't available for the Array Paths: %s", gb.Name)
|
||||
}
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&gb.TelemetryFieldKey), telemetrytypes.FieldDataTypeString, keys)
|
||||
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&gb.TelemetryFieldKey), telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
|
||||
fieldAlias := groupByColumnAlias(i, gb.Name)
|
||||
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, fieldAlias)))
|
||||
fieldNames = append(fieldNames, sqlbuilder.Escape(fieldAlias))
|
||||
}
|
||||
|
||||
var bucketAliases []string
|
||||
if heatmapField != nil {
|
||||
valueFragment, aliases, warnings, err := querybuilder.HeatmapBucketColumns(
|
||||
ctx, info, b.storage, sb, columnKey(heatmapField), query.BucketOptions, keys,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cteFragments = append(cteFragments, valueFragment)
|
||||
bucketAliases = aliases
|
||||
columnWarnings = append(columnWarnings, warnings...)
|
||||
}
|
||||
|
||||
// Aggregations
|
||||
allAggChArgs := make([]any, 0)
|
||||
for i, agg := range query.Aggregations {
|
||||
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
|
||||
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(
|
||||
ctx, orgID, start, end, agg.Expression,
|
||||
uint64(query.StepInterval.Seconds()),
|
||||
keys,
|
||||
@@ -487,6 +549,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, aggWarnings...)
|
||||
allAggChArgs = append(allAggChArgs, chArgs...)
|
||||
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, i))
|
||||
}
|
||||
@@ -521,6 +584,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
// Group by all dimensions
|
||||
sb.GroupBy("ts")
|
||||
sb.GroupBy(fieldNames...)
|
||||
sb.GroupBy(bucketAliases...)
|
||||
if query.Having != nil && query.Having.Expression != "" {
|
||||
// Rewrite having expression to use SQL column names
|
||||
rewriter := querybuilder.NewHavingExpressionRewriter()
|
||||
@@ -555,6 +619,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
} else {
|
||||
sb.GroupBy("ts")
|
||||
sb.GroupBy(fieldNames...)
|
||||
sb.GroupBy(bucketAliases...)
|
||||
if query.Having != nil && query.Having.Expression != "" {
|
||||
rewriter := querybuilder.NewHavingExpressionRewriter()
|
||||
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
|
||||
@@ -589,7 +654,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
CostGuard: b.costGuardFor(ctx, orgID, preparedWhereClause.RequiresCostGuard),
|
||||
}
|
||||
@@ -610,8 +675,9 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
|
||||
) (*qbtypes.Statement, error) {
|
||||
|
||||
var (
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
columnWarnings []string
|
||||
)
|
||||
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
|
||||
@@ -631,10 +697,11 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
|
||||
if !info.BodyJSONOn && (strings.Contains(gb.Name, telemetrytypes.ArraySep) || strings.Contains(gb.Name, telemetrytypes.ArrayAnyIndex)) {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "Group by/Aggregation isn't available for the Array Paths: %s", gb.Name)
|
||||
}
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&gb.TelemetryFieldKey), telemetrytypes.FieldDataTypeString, keys)
|
||||
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&gb.TelemetryFieldKey), telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
|
||||
fieldAlias := groupByColumnAlias(i, gb.Name)
|
||||
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, fieldAlias)))
|
||||
@@ -648,7 +715,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
|
||||
if len(query.Aggregations) > 0 {
|
||||
for idx := range query.Aggregations {
|
||||
aggExpr := query.Aggregations[idx]
|
||||
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
|
||||
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(
|
||||
ctx, orgID, start, end, aggExpr.Expression,
|
||||
rateInterval,
|
||||
keys,
|
||||
@@ -656,6 +723,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, aggWarnings...)
|
||||
allAggChArgs = append(allAggChArgs, chArgs...)
|
||||
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, idx))
|
||||
}
|
||||
@@ -721,7 +789,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
CostGuard: b.costGuardFor(ctx, orgID, preparedWhereClause.RequiresCostGuard),
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -1271,3 +1272,119 @@ func newSkipResourceFingerprintLogsBuilder(
|
||||
statementbuilder.Config{SkipResourceFingerprint: statementbuilder.SkipResourceFingerprint{Enabled: skipEnable, Threshold: threshold}},
|
||||
)
|
||||
}
|
||||
|
||||
func TestStatementBuilderHeatmap(t *testing.T) {
|
||||
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||
startTs := uint64(releaseTime.UnixNano()) + uint64(24*time.Hour.Nanoseconds())
|
||||
endTs := uint64(releaseTime.UnixNano()) + uint64(48*time.Hour.Nanoseconds())
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
expected qbtypes.Statement
|
||||
}{
|
||||
{
|
||||
name: "AttributeField_LogAxis_RequiresField",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "duration"}},
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'cartservice'"},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'duration'), toFloat64(attributes_number['duration']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'duration') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, __bucket_min, __bucket_max",
|
||||
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705397400), uint64(1705485600), "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AttributeField_GroupByWithLimit_LinearAxis_RequiresFieldInLimit",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "http.status_code"}},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
Limit: 5,
|
||||
BucketOptions: &qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLinear, Spec: qbtypes.LinearBucketsSpec{MaxValue: 600, NumBuckets: 6}},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH ifNull(multiIf(mapContains(attributes_number, 'http.status_code'), toFloat64(attributes_number['http.status_code']), NULL), 0) AS __bucket_value, __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE mapContains(attributes_number, 'http.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 600, toFloat64(600), (least(greatest(ceil(__bucket_value * 6 / 600), 1), 6) - 1) * 600 / 6) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 600, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 6 / 600), 1), 6) * 600 / 6) AS __bucket_max, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE mapContains(attributes_number, 'http.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, __bucket_min, __bucket_max",
|
||||
Args: []any{"1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600), 5, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "QualifiedField_OrderedByAggregation_OrdersByCount",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "attribute.amount"}},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
Limit: 3,
|
||||
Order: []qbtypes.OrderBy{{
|
||||
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "amount", FieldContext: telemetrytypes.FieldContextAttribute}},
|
||||
Direction: qbtypes.OrderDirectionAsc,
|
||||
}},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH ifNull(multiIf(mapContains(attributes_number, 'amount'), toFloat64(attributes_number['amount']), NULL), 0) AS __bucket_value, __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE mapContains(attributes_number, 'amount') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 asc LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE mapContains(attributes_number, 'amount') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, __bucket_min, __bucket_max ORDER BY ts desc",
|
||||
Args: []any{"1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600), 3, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IntrinsicColumn_NoExists",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "log.severity_number"}},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH ifNull(toFloat64(severity_number), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, __bucket_min, __bucket_max",
|
||||
Args: []any{"1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Distribution_AttributeField_SameStatementAsHeatmap",
|
||||
requestType: qbtypes.RequestTypeDistribution,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "duration"}},
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'cartservice'"},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'duration'), toFloat64(attributes_number['duration']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'duration') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, __bucket_min, __bucket_max",
|
||||
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705397400), uint64(1705485600), "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
fl := flaggertest.New(t)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
storage := logstelemetryschema.NewStorage()
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
storage,
|
||||
aggExprRewriter,
|
||||
logstelemetryschema.DefaultFullTextColumn,
|
||||
fl,
|
||||
nil,
|
||||
statementbuilder.Config{SkipResourceFingerprint: statementbuilder.SkipResourceFingerprint{Enabled: false, Threshold: 100000}},
|
||||
)
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
statement, err := statementBuilder.Build(context.Background(), valuer.UUID{}, startTs, endTs, testCase.requestType, testCase.query, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expected.Query, statement.Query)
|
||||
assert.Equal(t, testCase.expected.Args, statement.Args)
|
||||
assert.Equal(t, testCase.expected.Warnings, statement.Warnings)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,8 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDeltaFastPath(
|
||||
))
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, nil, telemetrytypes.SignalMetrics, &telemetrytypes.MetricContext{MetricName: query.Aggregations[0].MetricName}, start, end)
|
||||
for i, g := range query.GroupBy {
|
||||
col, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
// the meter path returns no warnings, filter warnings included
|
||||
col, _, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -235,7 +236,8 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDelta(
|
||||
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, nil, telemetrytypes.SignalMetrics, &telemetrytypes.MetricContext{MetricName: query.Aggregations[0].MetricName}, start, end)
|
||||
for i, g := range query.GroupBy {
|
||||
col, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
// the meter path returns no warnings, filter warnings included
|
||||
col, _, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -311,7 +313,8 @@ func (b *meterQueryStatementBuilder) buildTemporalAggCumulativeOrUnspecified(
|
||||
))
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, nil, telemetrytypes.SignalMetrics, &telemetrytypes.MetricContext{MetricName: query.Aggregations[0].MetricName}, start, end)
|
||||
for i, g := range query.GroupBy {
|
||||
col, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
// the meter path returns no warnings, filter warnings included
|
||||
col, _, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
@@ -4,14 +4,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/clickhousesql"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
@@ -320,7 +317,8 @@ func (b *StatementBuilder) buildReducedTimeSeriesCTE(
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedLocalTableName))
|
||||
sb.Select("fingerprint")
|
||||
for i, g := range query.GroupBy {
|
||||
col, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
// buildTimeSeriesCTE resolves the same keys and reports their warnings
|
||||
col, _, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -544,11 +542,16 @@ func (b *StatementBuilder) buildTimeSeriesCTE(
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, tsTable))
|
||||
|
||||
sb.Select("fingerprint")
|
||||
var columnWarnings []string
|
||||
for i, g := range query.GroupBy {
|
||||
col, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
col, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
// the histogram rewrite adds le itself, so its warning is not the caller's
|
||||
if query.Aggregations[0].Type != metrictypes.HistogramType || !isHistogramBucket(g) {
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
}
|
||||
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", col, GroupByColumnAlias(i, g.Name))))
|
||||
}
|
||||
|
||||
@@ -577,7 +580,7 @@ func (b *StatementBuilder) buildTimeSeriesCTE(
|
||||
|
||||
q, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
// the caller joins this into another builder, which compiles it again
|
||||
return fmt.Sprintf("(%s) AS filtered_time_series", sqlbuilder.Escape(q)), args, preparedWhereClause.Warnings, nil
|
||||
return fmt.Sprintf("(%s) AS filtered_time_series", sqlbuilder.Escape(q)), args, append(preparedWhereClause.Warnings, columnWarnings...), nil
|
||||
}
|
||||
|
||||
func (b *StatementBuilder) buildTemporalAggregationCTE(
|
||||
@@ -911,6 +914,10 @@ const (
|
||||
|
||||
heatmapValueAlias = "__result_0"
|
||||
heatmapWindow = "__heatmap_window"
|
||||
|
||||
// The column __spatial_aggregation_cte carries the sample in, which is what
|
||||
// the bucket axis is cut from.
|
||||
heatmapValueColumn = "value"
|
||||
)
|
||||
|
||||
func isHistogramBucket(k qbtypes.GroupByKey) bool { return k.Name == histogramBucketKey }
|
||||
@@ -976,10 +983,7 @@ func buildValueHeatmapFinalSelect(
|
||||
args []any,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
bucketMin, bucketMax, err := renderHeatmapBucketExprs(*query.Aggregations[0].HeatmapBucketing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bucketMin, bucketMax := querybuilder.RenderHeatmapBucketExprs(*query.Aggregations[0].HeatmapBucketing, heatmapValueColumn)
|
||||
|
||||
groupAliases := GroupByAliases(query.GroupBy)
|
||||
|
||||
@@ -997,61 +1001,6 @@ func buildValueHeatmapFinalSelect(
|
||||
return &qbtypes.Statement{Query: combined + q, Args: append(args, a...)}, nil
|
||||
}
|
||||
|
||||
// renderHeatmapBucketExprs renders the bucket (min, max] that `value` falls in.
|
||||
// Only the bucket under everything the axis covers is open below, and only the
|
||||
// one over it is open above.
|
||||
func renderHeatmapBucketExprs(bucketing qbtypes.HeatmapBucketing) (minExpr, maxExpr string, err error) {
|
||||
switch bucketing.Kind {
|
||||
case qbtypes.BucketsKindLinear:
|
||||
return renderLinearBucketExprs(bucketing)
|
||||
case qbtypes.BucketsKindLog:
|
||||
return renderLogBucketExprs()
|
||||
default:
|
||||
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"unsupported bucketsScaling %q for heatmap requests", bucketing.Kind.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
func renderLinearBucketExprs(bucketing qbtypes.HeatmapBucketing) (string, string, error) {
|
||||
maxValue := formatFloat(bucketing.MaxValue)
|
||||
numBuckets := strconv.Itoa(bucketing.NumBuckets)
|
||||
index := fmt.Sprintf("least(greatest(ceil(value * %s / %s), 1), %s)", numBuckets, maxValue, numBuckets)
|
||||
|
||||
minExpr := fmt.Sprintf(
|
||||
"multiIf(value <= 0, toFloat64('-Inf'), value > %s, toFloat64(%s), (%s - 1) * %s / %s)",
|
||||
maxValue, maxValue, index, maxValue, numBuckets,
|
||||
)
|
||||
maxExpr := fmt.Sprintf(
|
||||
"multiIf(value <= 0, toFloat64(0), value > %s, toFloat64('+Inf'), %s * %s / %s)",
|
||||
maxValue, index, maxValue, numBuckets,
|
||||
)
|
||||
return minExpr, maxExpr, nil
|
||||
}
|
||||
|
||||
// ClickHouse buckets at MaxLogScale whatever HeatmapBucketing.LogScale asks for;
|
||||
// postprocessing folds the axis down afterwards.
|
||||
func renderLogBucketExprs() (string, string, error) {
|
||||
bucketsPerDoubling := formatFloat(math.Exp2(qbtypes.MaxLogScale))
|
||||
lowest := formatFloat(qbtypes.MinLogUpperBound)
|
||||
highest := formatFloat(qbtypes.MaxLogUpperBound)
|
||||
|
||||
minExpr := fmt.Sprintf(
|
||||
"multiIf(value <= 0, toFloat64('-Inf'), value <= %s, toFloat64(0), value > %s, toFloat64(%s), pow(2, (ceil(log2(value) * %s) - 1) / %s))",
|
||||
lowest, highest, highest, bucketsPerDoubling, bucketsPerDoubling,
|
||||
)
|
||||
maxExpr := fmt.Sprintf(
|
||||
"multiIf(value <= 0, toFloat64(0), value <= %s, %s, value > %s, toFloat64('+Inf'), pow(2, ceil(log2(value) * %s) / %s))",
|
||||
lowest, lowest, highest, bucketsPerDoubling, bucketsPerDoubling,
|
||||
)
|
||||
return minExpr, maxExpr, nil
|
||||
}
|
||||
|
||||
// formatFloat renders a float64 as the shortest literal that reads back as the
|
||||
// same value, so an upper bound computed from it is identical on every row.
|
||||
func formatFloat(v float64) string {
|
||||
return strconv.FormatFloat(v, 'g', -1, 64)
|
||||
}
|
||||
|
||||
func GroupByColumnAlias(i int, name string) string {
|
||||
if name == histogramBucketKey {
|
||||
return clickhousesql.Identifier(histogramBucketKey)
|
||||
|
||||
@@ -512,6 +512,36 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "Gauge_GroupByUnknownLe_Warns",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
Temporality: metrictypes.Unspecified,
|
||||
TimeAggregation: metrictypes.TimeAggregationMax,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationMax,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "le",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `le`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `le` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `le`, max(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `le`) SELECT * FROM __spatial_aggregation_cte ORDER BY `le`, ts",
|
||||
Args: []any{"system.memory.usage", uint64(1747936800000), uint64(1747983420000), "unspecified", "system.memory.usage", uint64(1747947360000), uint64(1747983420000), 0},
|
||||
Warnings: []string{"key `le` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos."},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_histogram_percentile2",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
@@ -571,7 +601,8 @@ func TestStatementBuilder(t *testing.T) {
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'k8s.statefulset.name') AS `__GROUP_BY_KEY_0_k8s.statefulset.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'k8s.statefulset.name') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_k8s.statefulset.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_k8s.statefulset.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_k8s.statefulset.name`, ts",
|
||||
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", "my-statefulset", "signoz_calls_total", uint64(1747947360000), uint64(1747983420000), 0},
|
||||
Warnings: []string{"key `k8s.statefulset.name` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos."},
|
||||
// the filter and the group by each report the key; the response dedupes them
|
||||
Warnings: []string{"key `k8s.statefulset.name` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos.", "key `k8s.statefulset.name` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos."},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
|
||||
@@ -36,7 +36,8 @@ func (r *columnResolver) ValueFor(ctx context.Context, q qbtypes.QueryInfo, key
|
||||
if cands := r.keys[key.Name]; len(cands) > 0 {
|
||||
key = cands[0]
|
||||
}
|
||||
expr, err := querybuilder.ResolveColumn(ctx, q, r.storage, key, dt, r.keys)
|
||||
// aggregate renders return no warnings
|
||||
expr, _, err := querybuilder.ResolveColumn(ctx, q, r.storage, key, dt, r.keys)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -478,19 +478,21 @@ func groupBySelectors(groupBy []qbtypes.GroupByKey) []*telemetrytypes.FieldKeySe
|
||||
|
||||
// resolveGroupColumns resolves group-by keys through the storage for selection
|
||||
// inside the per-trace scan; keys must cover the group-by selectors.
|
||||
func (b *scopedTraceStatementBuilder) resolveGroupColumns(ctx context.Context, q qbtypes.QueryInfo, groupBy []qbtypes.GroupByKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]groupColumn, error) {
|
||||
func (b *scopedTraceStatementBuilder) resolveGroupColumns(ctx context.Context, q qbtypes.QueryInfo, groupBy []qbtypes.GroupByKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]groupColumn, []string, error) {
|
||||
if len(groupBy) == 0 {
|
||||
return nil, nil
|
||||
return nil, nil, nil
|
||||
}
|
||||
out := make([]groupColumn, 0, len(groupBy))
|
||||
var warnings []string
|
||||
for i := range groupBy {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, q, b.storage, &groupBy[i].TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, q, b.storage, &groupBy[i].TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
warnings = append(warnings, keyWarnings...)
|
||||
out = append(out, groupColumn{alias: groupByColumnAlias(i, groupBy[i].Name), expr: sqlbuilder.Escape(expr)})
|
||||
}
|
||||
return out, nil
|
||||
return out, warnings, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -598,7 +600,7 @@ func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
|
||||
}
|
||||
}
|
||||
|
||||
groupCols, err := b.resolveGroupColumns(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), query.GroupBy, keys)
|
||||
groupCols, groupWarnings, err := b.resolveGroupColumns(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), query.GroupBy, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -734,7 +736,7 @@ func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
|
||||
return &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: msc.warnings,
|
||||
Warnings: append(msc.warnings, groupWarnings...),
|
||||
WarningsDocURL: msc.warnURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -178,6 +178,8 @@ func (b *traceQueryStatementBuilder) Build(
|
||||
return b.buildListQuery(ctx, orgID, q, query, start, end, keys, variables, isSelectFieldsEmpty)
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
return b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeHeatmap, qbtypes.RequestTypeDistribution:
|
||||
return b.buildHeatmapTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
return b.buildScalarQuery(ctx, orgID, q, query, start, end, keys, variables, false, false)
|
||||
case qbtypes.RequestTypeTrace:
|
||||
@@ -362,8 +364,9 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
) (*qbtypes.Statement, error) {
|
||||
|
||||
var (
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
columnWarnings []string
|
||||
)
|
||||
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
|
||||
@@ -382,10 +385,11 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
|
||||
for i, field := range query.SelectFields {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", expr, selectColumnAlias(i, field.Name))))
|
||||
}
|
||||
|
||||
@@ -406,10 +410,11 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
|
||||
// Add order by
|
||||
for _, orderBy := range query.Order {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
sb.OrderBy(fmt.Sprintf("%s %s", sqlbuilder.Escape(expr), orderBy.Direction.StringValue()))
|
||||
}
|
||||
|
||||
@@ -432,7 +437,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
}
|
||||
|
||||
@@ -567,10 +572,53 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, query, start, end, keys, variables, nil)
|
||||
}
|
||||
|
||||
// buildHeatmapTimeSeriesQuery counts rows per (time bucket, group, value
|
||||
// bucket) of the field its aggregation names.
|
||||
func (b *traceQueryStatementBuilder) buildHeatmapTimeSeriesQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
heatmapField, countQuery, err := qbtypes.HeatmapCountQuery(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
|
||||
mayBeAbsent, err := querybuilder.HeatmapFieldMayBeAbsent(ctx, info, b.storage, heatmapField, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mayBeAbsent {
|
||||
countQuery = qbtypes.RequireHeatmapField(countQuery, *heatmapField)
|
||||
}
|
||||
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, countQuery, start, end, keys, variables, heatmapField)
|
||||
}
|
||||
|
||||
// buildTimeSeriesOrHeatmapQuery builds one row per (time bucket, group). A
|
||||
// non-nil heatmapField makes it a heatmap instead: the bucket a row's
|
||||
// heatmapField falls in joins the group keys, so a row is one cell.
|
||||
func (b *traceQueryStatementBuilder) buildTimeSeriesOrHeatmapQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
heatmapField *telemetrytypes.TelemetryFieldKey,
|
||||
) (*qbtypes.Statement, error) {
|
||||
|
||||
var (
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
columnWarnings []string
|
||||
)
|
||||
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
|
||||
@@ -596,19 +644,33 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
fieldNames := make([]string, 0, len(query.GroupBy))
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
|
||||
for i, gb := range query.GroupBy {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
fieldAlias := groupByColumnAlias(i, gb.Name)
|
||||
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, fieldAlias)))
|
||||
fieldNames = append(fieldNames, sqlbuilder.Escape(fieldAlias))
|
||||
}
|
||||
|
||||
var bucketAliases []string
|
||||
if heatmapField != nil {
|
||||
valueFragment, aliases, warnings, err := querybuilder.HeatmapBucketColumns(
|
||||
ctx, info, b.storage, sb, heatmapField, query.BucketOptions, keys,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cteFragments = append(cteFragments, valueFragment)
|
||||
bucketAliases = aliases
|
||||
columnWarnings = append(columnWarnings, warnings...)
|
||||
}
|
||||
|
||||
// Aggregations
|
||||
allAggChArgs := make([]any, 0)
|
||||
for i, agg := range query.Aggregations {
|
||||
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
|
||||
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(
|
||||
ctx, orgID, start, end, agg.Expression,
|
||||
uint64(query.StepInterval.Seconds()),
|
||||
keys,
|
||||
@@ -616,6 +678,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, aggWarnings...)
|
||||
allAggChArgs = append(allAggChArgs, chArgs...)
|
||||
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, i))
|
||||
}
|
||||
@@ -647,6 +710,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
// Group by all dimensions
|
||||
sb.GroupBy("ts")
|
||||
sb.GroupBy(fieldNames...)
|
||||
sb.GroupBy(bucketAliases...)
|
||||
if query.Having != nil && query.Having.Expression != "" {
|
||||
rewriter := querybuilder.NewHavingExpressionRewriter()
|
||||
rewrittenExpr, err := rewriter.RewriteForTraces(query.Having.Expression, query.Aggregations)
|
||||
@@ -680,6 +744,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
} else {
|
||||
sb.GroupBy("ts")
|
||||
sb.GroupBy(fieldNames...)
|
||||
sb.GroupBy(bucketAliases...)
|
||||
if query.Having != nil && query.Having.Expression != "" {
|
||||
rewriter := querybuilder.NewHavingExpressionRewriter()
|
||||
rewrittenExpr, err := rewriter.RewriteForTraces(query.Having.Expression, query.Aggregations)
|
||||
@@ -714,7 +779,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
}
|
||||
|
||||
@@ -735,8 +800,9 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
) (*qbtypes.Statement, error) {
|
||||
|
||||
var (
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
cteFragments []string
|
||||
cteArgs [][]any
|
||||
columnWarnings []string
|
||||
)
|
||||
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
|
||||
@@ -760,10 +826,11 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
fieldNames := make([]string, 0, len(query.GroupBy))
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
|
||||
for i, gb := range query.GroupBy {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
fieldAlias := groupByColumnAlias(i, gb.Name)
|
||||
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, fieldAlias)))
|
||||
fieldNames = append(fieldNames, sqlbuilder.Escape(fieldAlias))
|
||||
@@ -776,7 +843,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
if len(query.Aggregations) > 0 {
|
||||
for idx := range query.Aggregations {
|
||||
aggExpr := query.Aggregations[idx]
|
||||
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
|
||||
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(
|
||||
ctx, orgID, start, end, aggExpr.Expression,
|
||||
rateInterval,
|
||||
keys,
|
||||
@@ -784,6 +851,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, aggWarnings...)
|
||||
allAggChArgs = append(allAggChArgs, chArgs...)
|
||||
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, idx))
|
||||
}
|
||||
@@ -849,7 +917,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
}
|
||||
|
||||
|
||||
@@ -756,8 +756,9 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, name AS `__SELECT_KEY_3_name`, resource_string_service$$name AS `__SELECT_KEY_4_serviceName`, duration_nano AS `__SELECT_KEY_5_durationNano`, http_method AS `__SELECT_KEY_6_httpMethod`, multiIf(mapContains(attributes_string, 'non-existent.key'), attributes_string['non-existent.key'], mapContains(attributes_number, 'non-existent.key'), toString(attributes_number['non-existent.key']), mapContains(attributes_bool, 'non-existent.key'), toString(attributes_bool['non-existent.key']), NULL) AS `__SELECT_KEY_7_non-existent.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, name AS `__SELECT_KEY_3_name`, resource_string_service$$name AS `__SELECT_KEY_4_serviceName`, duration_nano AS `__SELECT_KEY_5_durationNano`, http_method AS `__SELECT_KEY_6_httpMethod`, multiIf(mapContains(attributes_string, 'non-existent.key'), attributes_string['non-existent.key'], mapContains(attributes_number, 'non-existent.key'), toString(attributes_number['non-existent.key']), mapContains(attributes_bool, 'non-existent.key'), toString(attributes_bool['non-existent.key']), NULL) AS `__SELECT_KEY_7_non-existent.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
Warnings: []string{querybuilder.NewKeyNotFoundWarning("non-existent.key")},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
@@ -926,8 +927,9 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.version::String <> '', scope.version::String, NULL) AS `__SELECT_KEY_3_scope.version` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.version::String <> '', scope.version::String, NULL) AS `__SELECT_KEY_3_scope.version` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
Warnings: []string{querybuilder.NewKeyNotFoundWarning("scope.version")},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1017,8 +1019,9 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.name::String <> '', scope.name::String, NULL) AS `__SELECT_KEY_3_scope.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.name::String <> '', scope.name::String, NULL) AS `__SELECT_KEY_3_scope.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
Warnings: []string{querybuilder.NewKeyNotFoundWarning("scope.name")},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -2110,3 +2113,103 @@ func TestStatementBuilderSemconvSingleSpelling(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'deployment.environment') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT toString(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL)) AS `__GROUP_BY_KEY_0_deployment.environment.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY __result_0 DESC", q.Query)
|
||||
}
|
||||
|
||||
func TestStatementBuilderHeatmap(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
|
||||
expected qbtypes.Statement
|
||||
}{
|
||||
{
|
||||
name: "DurationNano_CollidingAttribute_NoExists",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "duration_nano"}},
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'redis-manual'"},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(toFloat64(duration_nano), 0) AS __bucket_value SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY ts, __bucket_min, __bucket_max",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AttributeField_GroupByWithLimit_LinearAxis_RequiresFieldInLimit",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "cart.items_count"}},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
Limit: 5,
|
||||
BucketOptions: &qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLinear, Spec: qbtypes.LinearBucketsSpec{MaxValue: 50, NumBuckets: 5}},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH ifNull(multiIf(`attribute_number_cart$$items_count_exists` = true, toFloat64(`attribute_number_cart$$items_count`), NULL), 0) AS __bucket_value, __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE `attribute_number_cart$$items_count_exists` = true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 50, toFloat64(50), (least(greatest(ceil(__bucket_value * 5 / 50), 1), 5) - 1) * 50 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 50, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 50), 1), 5) * 50 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE `attribute_number_cart$$items_count_exists` = true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, __bucket_min, __bucket_max",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 5, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "QualifiedField_OrderedByAggregation_OrdersByCount",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "attribute.metric.max_count"}},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
Limit: 3,
|
||||
Order: []qbtypes.OrderBy{{
|
||||
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "metric.max_count", FieldContext: telemetrytypes.FieldContextAttribute}},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
}},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH ifNull(multiIf(mapContains(attributes_number, 'metric.max_count'), toFloat64(attributes_number['metric.max_count']), NULL), 0) AS __bucket_value, __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE mapContains(attributes_number, 'metric.max_count') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 desc LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE mapContains(attributes_number, 'metric.max_count') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, __bucket_min, __bucket_max ORDER BY ts desc",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 3, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Distribution_DurationNano_SameStatementAsHeatmap",
|
||||
requestType: qbtypes.RequestTypeDistribution,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "duration_nano"}},
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'redis-manual'"},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(toFloat64(duration_nano), 0) AS __bucket_value SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY ts, __bucket_min, __bucket_max",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
releaseTime := time.Date(2025, 5, 22, 0, 0, 0, 0, time.UTC)
|
||||
fl := flaggertest.New(t)
|
||||
storage := tracestelemetryschema.NewStorage(nil)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
storage,
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
statement, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, testCase.requestType, testCase.query, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expected.Query, statement.Query)
|
||||
assert.Equal(t, testCase.expected.Args, statement.Args)
|
||||
assert.Equal(t, testCase.expected.Warnings, statement.Warnings)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,6 +462,7 @@ func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFro
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey, isSelectFieldsEmpty bool) (*qbtypes.Statement, error) {
|
||||
var columnWarnings []string
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
// Select core fields. These are always present so the trace operator
|
||||
@@ -490,12 +491,13 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
|
||||
if selectedFields[field.Name] {
|
||||
continue
|
||||
}
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
if err != nil {
|
||||
b.stmtBuilder.logger.WarnContext(ctx, "failed to map select field",
|
||||
slog.String("field", field.Name), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", expr, selectColumnAlias(i, field.Name))))
|
||||
selectedFields[field.Name] = true
|
||||
}
|
||||
@@ -511,10 +513,11 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
|
||||
// Add order by support
|
||||
orderApplied := false
|
||||
for _, orderBy := range b.operator.Order {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
sb.OrderBy(fmt.Sprintf("%s %s", sqlbuilder.Escape(expr), orderBy.Direction.StringValue()))
|
||||
orderApplied = true
|
||||
}
|
||||
@@ -535,8 +538,9 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
|
||||
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return &qbtypes.Statement{
|
||||
Query: sql,
|
||||
Args: args,
|
||||
Query: sql,
|
||||
Args: args,
|
||||
Warnings: columnWarnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -622,6 +626,7 @@ func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySe
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
var columnWarnings []string
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
sb.Select(fmt.Sprintf(
|
||||
@@ -631,7 +636,7 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
|
||||
|
||||
info := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return nil, errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
@@ -640,12 +645,13 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
|
||||
err,
|
||||
)
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, clickhousesql.Identifier(gb.Name))))
|
||||
}
|
||||
|
||||
var allAggChArgs []any
|
||||
for i, agg := range b.operator.Aggregations {
|
||||
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
|
||||
rewritten, chArgs, aggWarnings, err := b.stmtBuilder.aggExprRewriter.Rewrite(
|
||||
ctx,
|
||||
b.orgID,
|
||||
b.start,
|
||||
@@ -662,6 +668,7 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
|
||||
err,
|
||||
)
|
||||
}
|
||||
columnWarnings = append(columnWarnings, aggWarnings...)
|
||||
allAggChArgs = append(allAggChArgs, chArgs...)
|
||||
|
||||
alias := fmt.Sprintf("__result_%d", i)
|
||||
@@ -700,8 +707,9 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
|
||||
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
|
||||
return &qbtypes.Statement{
|
||||
Query: sql,
|
||||
Args: args,
|
||||
Query: sql,
|
||||
Args: args,
|
||||
Warnings: columnWarnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -722,13 +730,14 @@ func (b *traceOperatorCTEBuilder) buildTraceSummaryCTE(selectFromCTE string) {
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
var columnWarnings []string
|
||||
b.buildTraceSummaryCTE(selectFromCTE)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
info := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return nil, errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
@@ -737,6 +746,7 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
err,
|
||||
)
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, clickhousesql.Identifier(gb.Name))))
|
||||
}
|
||||
|
||||
@@ -744,7 +754,7 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
|
||||
var allAggChArgs []any
|
||||
for i, agg := range b.operator.Aggregations {
|
||||
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
|
||||
rewritten, chArgs, aggWarnings, err := b.stmtBuilder.aggExprRewriter.Rewrite(
|
||||
ctx,
|
||||
b.orgID,
|
||||
b.start,
|
||||
@@ -761,6 +771,7 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
err,
|
||||
)
|
||||
}
|
||||
columnWarnings = append(columnWarnings, aggWarnings...)
|
||||
allAggChArgs = append(allAggChArgs, chArgs...)
|
||||
|
||||
alias := fmt.Sprintf("__result_%d", i)
|
||||
@@ -849,17 +860,19 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
|
||||
return &qbtypes.Statement{
|
||||
Query: sql,
|
||||
Args: args,
|
||||
Query: sql,
|
||||
Args: args,
|
||||
Warnings: columnWarnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
var columnWarnings []string
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
info := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return nil, errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
@@ -868,12 +881,13 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
|
||||
err,
|
||||
)
|
||||
}
|
||||
columnWarnings = append(columnWarnings, keyWarnings...)
|
||||
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, clickhousesql.Identifier(gb.Name))))
|
||||
}
|
||||
|
||||
var allAggChArgs []any
|
||||
for i, agg := range b.operator.Aggregations {
|
||||
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
|
||||
rewritten, chArgs, aggWarnings, err := b.stmtBuilder.aggExprRewriter.Rewrite(
|
||||
ctx,
|
||||
b.orgID,
|
||||
b.start,
|
||||
@@ -890,6 +904,7 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
|
||||
err,
|
||||
)
|
||||
}
|
||||
columnWarnings = append(columnWarnings, aggWarnings...)
|
||||
allAggChArgs = append(allAggChArgs, chArgs...)
|
||||
|
||||
alias := fmt.Sprintf("__result_%d", i)
|
||||
@@ -939,8 +954,9 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
|
||||
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
|
||||
return &qbtypes.Statement{
|
||||
Query: sql,
|
||||
Args: args,
|
||||
Query: sql,
|
||||
Args: args,
|
||||
Warnings: columnWarnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -505,7 +505,7 @@ func (m *storage) Fallback(_ context.Context, _ qbtypes.QueryInfo, key *telemetr
|
||||
var keys []*telemetrytypes.TelemetryFieldKey
|
||||
switch key.FieldContext {
|
||||
case telemetrytypes.FieldContextBody:
|
||||
keys = []*telemetrytypes.TelemetryFieldKey{telemetrytypes.NewTelemetryFieldKey(key.Name, key.FieldContext, key.FieldDataType)}
|
||||
keys = []*telemetrytypes.TelemetryFieldKey{bodyPath(key.Name, key.FieldDataType, value)}
|
||||
case telemetrytypes.FieldContextUnspecified:
|
||||
if column, ok := logsV2Columns[key.Name]; ok {
|
||||
keys = []*telemetrytypes.TelemetryFieldKey{{Name: key.Name, FieldContext: telemetrytypes.FieldContextLog, FieldDataType: querybuilder.ColumnDataType(column)}}
|
||||
|
||||
@@ -782,7 +782,7 @@ func TestIntrinsicColumnFirst(t *testing.T) {
|
||||
|
||||
t.Run("declared scope path selects without a literal spelling", func(t *testing.T) {
|
||||
key := telemetrytypes.TelemetryFieldKey{Name: "scope.version", FieldContext: telemetrytypes.FieldContextScope}
|
||||
expr, err := querybuilder.ResolveColumn(ctx, q, storage, &key, telemetrytypes.FieldDataTypeUnspecified, map[string][]*telemetrytypes.TelemetryFieldKey{})
|
||||
expr, _, err := querybuilder.ResolveColumn(ctx, q, storage, &key, telemetrytypes.FieldDataTypeUnspecified, map[string][]*telemetrytypes.TelemetryFieldKey{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "multiIf(scope.version::String <> '', scope.version::String, NULL)", expr)
|
||||
})
|
||||
|
||||
@@ -228,7 +228,7 @@ func TestColumnExpressionForFamilyGroupBy(t *testing.T) {
|
||||
fl := familyFlagOn(t)
|
||||
storage := NewStorage(nil)
|
||||
|
||||
expr, err := querybuilder.ResolveColumn(context.Background(), querybuilder.NewQueryInfo(context.Background(), valuer.UUID{}, fl, telemetrytypes.SignalTraces, nil, startNs, endNs), storage, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, telemetrytypes.FieldDataTypeString, fieldKeys)
|
||||
expr, _, err := querybuilder.ResolveColumn(context.Background(), querybuilder.NewQueryInfo(context.Background(), valuer.UUID{}, fl, telemetrytypes.SignalTraces, nil, startNs, endNs), storage, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, telemetrytypes.FieldDataTypeString, fieldKeys)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "multiIf((multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL) IS NOT NULL OR multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL) IS NOT NULL), COALESCE(NULLIF(multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL), ''), NULLIF(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL), ''), ''), NULL)", expr)
|
||||
}
|
||||
|
||||
@@ -288,14 +288,14 @@ func TestColumnExpressionForAttributeJSON(t *testing.T) {
|
||||
|
||||
t.Run("group by string", func(t *testing.T) {
|
||||
key := attrKey("user.id", telemetrytypes.FieldDataTypeString, evo)
|
||||
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &key, telemetrytypes.FieldDataTypeString, nil)
|
||||
got, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &key, telemetrytypes.FieldDataTypeString, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "multiIf(attributes.`user.id` IS NOT NULL, attributes.`user.id`::String, mapContains(attributes_string, 'attribute.user.id'), attributes_string['attribute.user.id'], NULL)", got)
|
||||
})
|
||||
|
||||
t.Run("aggregation numeric", func(t *testing.T) {
|
||||
key := attrKey("latency", telemetrytypes.FieldDataTypeNumber, evo)
|
||||
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &key, telemetrytypes.FieldDataTypeFloat64, nil)
|
||||
got, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &key, telemetrytypes.FieldDataTypeFloat64, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "multiIf(toFloat64OrNull(attributes.`latency`::String) IS NOT NULL, toFloat64OrNull(attributes.`latency`::String), mapContains(attributes_number, 'attribute.latency'), toFloat64(attributes_number['attribute.latency']), NULL)", got)
|
||||
})
|
||||
@@ -362,7 +362,7 @@ func TestColumnExpressionForAttributeJSONTypeCollision(t *testing.T) {
|
||||
}
|
||||
|
||||
ref := attrKey("http.status_code", telemetrytypes.FieldDataTypeUnspecified, nil)
|
||||
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &ref, telemetrytypes.FieldDataTypeString, fieldKeys)
|
||||
got, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &ref, telemetrytypes.FieldDataTypeString, fieldKeys)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
"multiIf(attributes.`http.status_code` IS NOT NULL, attributes.`http.status_code`::String, toFloat64OrNull(attributes.`http.status_code`::String) IS NOT NULL, toString(toFloat64OrNull(attributes.`http.status_code`::String)), NULL)",
|
||||
@@ -386,7 +386,7 @@ func TestColumnExpressionForAttributeJSONTypeCollisionNumericAgg(t *testing.T) {
|
||||
}
|
||||
|
||||
ref := attrKey("http.status_code", telemetrytypes.FieldDataTypeUnspecified, nil)
|
||||
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &ref, telemetrytypes.FieldDataTypeFloat64, fieldKeys)
|
||||
got, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &ref, telemetrytypes.FieldDataTypeFloat64, fieldKeys)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
"multiIf(toFloat64OrNull(attributes.`http.status_code`::String) IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), attributes.`http.status_code` IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), NULL)",
|
||||
|
||||
@@ -295,7 +295,7 @@ func TestColumnExpressionForTemporalColumn(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
storage := NewStorage(nil)
|
||||
result, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &tc.key, tc.requiredDataType, nil)
|
||||
result, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &tc.key, tc.requiredDataType, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedResult, result)
|
||||
})
|
||||
@@ -326,7 +326,7 @@ func TestColumnExpressionForTimestampAttributeCollision(t *testing.T) {
|
||||
|
||||
t.Run("bare timestamp resolves to the intrinsic column alone", func(t *testing.T) {
|
||||
bare := telemetrytypes.TelemetryFieldKey{Name: "timestamp"}
|
||||
result, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &bare, telemetrytypes.FieldDataTypeFloat64, keys)
|
||||
result, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &bare, telemetrytypes.FieldDataTypeFloat64, keys)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "timestamp", result)
|
||||
})
|
||||
@@ -337,7 +337,7 @@ func TestColumnExpressionForTimestampAttributeCollision(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeNumber,
|
||||
}
|
||||
result, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &attr, telemetrytypes.FieldDataTypeFloat64, keys)
|
||||
result, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &attr, telemetrytypes.FieldDataTypeFloat64, keys)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "attributes_number['timestamp']")
|
||||
})
|
||||
@@ -432,7 +432,7 @@ func TestColumnExpressionForScopeDeclaredPath(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
storage := NewStorage(nil)
|
||||
result, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{}, storage, &tc.key, telemetrytypes.FieldDataTypeUnspecified, tc.keys)
|
||||
result, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{}, storage, &tc.key, telemetrytypes.FieldDataTypeUnspecified, tc.keys)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedResult, result)
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ type QueryBuilderFormula struct {
|
||||
|
||||
Offset int `json:"offset,omitempty" description:"The number of ordered series or rows to skip before the page starts. Requires limit."`
|
||||
|
||||
Having *Having `json:"having,omitempty" description:"Accepted but currently ignored; the computed values are returned unfiltered."`
|
||||
Having *Having `json:"having,omitempty" description:"A filter on the formula values, referencing the formula by its name or as __result, for example F1 > 0. A time series keeps only the points that match and drops a series with none left; a scalar result keeps only the rows that match."`
|
||||
|
||||
Functions []Function `json:"functions,omitzero" description:"Post-processing functions applied in order to each result series."`
|
||||
|
||||
@@ -118,6 +118,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 {
|
||||
|
||||
@@ -3,9 +3,11 @@ package querybuildertypesv5
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -16,6 +18,9 @@ const (
|
||||
HeatmapBucketMinColumn = "__bucket_min"
|
||||
HeatmapBucketMaxColumn = "__bucket_max"
|
||||
|
||||
// A logs or traces heatmap counts events, so its field renders as count().
|
||||
heatmapCountExpression = "count()"
|
||||
|
||||
DefaultNumBuckets = 60
|
||||
|
||||
// MaxLogScale is the resolution ClickHouse buckets every log heatmap at:
|
||||
@@ -81,6 +86,68 @@ func (b *BucketOptions) ToHeatmapBucketing() HeatmapBucketing {
|
||||
return resolved
|
||||
}
|
||||
|
||||
// HeatmapAggregationField returns the field a logs or traces heatmap buckets,
|
||||
// written as the bare field. ok is false for any other aggregation.
|
||||
func HeatmapAggregationField(expression string) (telemetrytypes.TelemetryFieldKey, bool) {
|
||||
keyText := strings.TrimSpace(expression)
|
||||
if keyText == "" || strings.ContainsAny(keyText, "() \t\n") {
|
||||
return telemetrytypes.TelemetryFieldKey{}, false
|
||||
}
|
||||
return telemetrytypes.GetFieldKeyFromKeyText(keyText), true
|
||||
}
|
||||
|
||||
// HeatmapCountQuery splits a logs or traces heatmap query into the field its
|
||||
// aggregation buckets and a copy of query that counts events in its place,
|
||||
// which is what every cell holds.
|
||||
func HeatmapCountQuery[T LogAggregation | TraceAggregation](query QueryBuilderQuery[T]) (*telemetrytypes.TelemetryFieldKey, QueryBuilderQuery[T], error) {
|
||||
if len(query.Aggregations) != 1 {
|
||||
return nil, query, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"a heatmap renders one bucket axis, but the query has %d aggregations", len(query.Aggregations))
|
||||
}
|
||||
|
||||
query.Aggregations = slices.Clone(query.Aggregations)
|
||||
var expression *string
|
||||
switch aggregation := any(&query.Aggregations[0]).(type) {
|
||||
case *LogAggregation:
|
||||
expression = &aggregation.Expression
|
||||
case *TraceAggregation:
|
||||
expression = &aggregation.Expression
|
||||
}
|
||||
|
||||
field, ok := HeatmapAggregationField(*expression)
|
||||
if !ok {
|
||||
return nil, query, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"a logs or traces heatmap counts events by the field named as its aggregation, got %q", *expression)
|
||||
}
|
||||
|
||||
// An order on the aggregation names it by its expression, which decoding
|
||||
// splits into a context, a name and a data type, and which metadata may
|
||||
// complete, as it does any field.
|
||||
query.Order = slices.Clone(query.Order)
|
||||
for i := range query.Order {
|
||||
key := query.Order[i].Key.TelemetryFieldKey
|
||||
sameField := key.Name == field.Name &&
|
||||
(field.FieldContext == telemetrytypes.FieldContextUnspecified || key.FieldContext == field.FieldContext) &&
|
||||
(field.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || key.FieldDataType == field.FieldDataType)
|
||||
if key.Name == *expression || sameField {
|
||||
query.Order[i].Key = OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: heatmapCountExpression}}
|
||||
}
|
||||
}
|
||||
*expression = heatmapCountExpression
|
||||
|
||||
return &field, query, nil
|
||||
}
|
||||
|
||||
// RequireHeatmapField keeps only the rows of query that carry field.
|
||||
func RequireHeatmapField[T LogAggregation | TraceAggregation](query QueryBuilderQuery[T], field telemetrytypes.TelemetryFieldKey) QueryBuilderQuery[T] {
|
||||
exists := telemetrytypes.TelemetryFieldKeyToText(&field) + " EXISTS"
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
exists = "(" + query.Filter.Expression + ") AND " + exists
|
||||
}
|
||||
query.Filter = &Filter{Expression: exists}
|
||||
return query
|
||||
}
|
||||
|
||||
// This cannot be called in validateBucketCounting cuz metric type is resolved in querier.go.
|
||||
func (a *MetricAggregation) VerifyAndApplyBucketOptions(bucketOptions *BucketOptions) error {
|
||||
switch a.Type {
|
||||
|
||||
@@ -214,8 +214,8 @@ type Resolved struct {
|
||||
|
||||
type AggExprRewriter interface {
|
||||
// Rewrite rewrites the aggregation expression to be used in the query.
|
||||
Rewrite(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, expr string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) (string, []any, error)
|
||||
RewriteMulti(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, exprs []string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]string, [][]any, error)
|
||||
Rewrite(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, expr string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) (string, []any, []string, error)
|
||||
RewriteMulti(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, exprs []string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]string, [][]any, []string, error)
|
||||
}
|
||||
|
||||
type Statement struct {
|
||||
|
||||
@@ -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()...))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -499,6 +499,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
|
||||
}
|
||||
|
||||
@@ -586,19 +598,10 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate request type
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap, RequestTypeDistribution:
|
||||
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, distribution",
|
||||
)
|
||||
if err := r.validateRequestType(); err != nil {
|
||||
return err
|
||||
}
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
|
||||
if err := r.validateBucketCounting(); err != nil {
|
||||
return err
|
||||
@@ -639,14 +642,10 @@ func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var opts []ValidationOption
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap, RequestTypeDistribution:
|
||||
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, distribution")
|
||||
if err := r.validateRequestType(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := GetValidationOptions(r.RequestType)
|
||||
|
||||
if err := r.validateBucketCounting(); err != nil {
|
||||
return nil, err
|
||||
@@ -703,6 +702,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
|
||||
@@ -921,15 +934,26 @@ func (r *QueryRangeRequest) validateBucketCounting() error {
|
||||
continue
|
||||
}
|
||||
enabled++
|
||||
// An AI query decodes to the traces spec, so it lands here too. Admitting
|
||||
// either signal means capping Aggregations at one: each carries its own
|
||||
// Meta.Buckets, and a heatmap renders against a single bucket axis.
|
||||
case QueryBuilderQuery[LogAggregation], QueryBuilderQuery[TraceAggregation]:
|
||||
return errors.New(errors.TypeUnsupported, errors.CodeUnsupported,
|
||||
"heatmaps are not supported for the logs and traces signals yet")
|
||||
// An AI query decodes to the traces spec, so it lands here too.
|
||||
case QueryBuilderQuery[LogAggregation]:
|
||||
if err := validateEventHeatmapQuery(r.RequestType, spec.Functions, spec.Having, aggregationExpressions(spec.Aggregations), spec.Disabled); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.Disabled {
|
||||
continue
|
||||
}
|
||||
enabled++
|
||||
case QueryBuilderQuery[TraceAggregation]:
|
||||
if err := validateEventHeatmapQuery(r.RequestType, spec.Functions, spec.Having, aggregationExpressions(spec.Aggregations), spec.Disabled); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.Disabled {
|
||||
continue
|
||||
}
|
||||
enabled++
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"%s requests support one metrics builder query, one formula over them, one clickhouse query, or one promql query, got %q", r.RequestType.StringValue(), envelope.Type.StringValue())
|
||||
"%s requests support one builder query, one formula over them, one clickhouse query, or one promql query, got %q", r.RequestType.StringValue(), envelope.Type.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,6 +1057,48 @@ func validateBucketCountingQuery(requestType RequestType, functions []Function,
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateEventHeatmapQuery holds a logs or traces query to what a per-event
|
||||
// heatmap can draw. A disabled query feeds a formula rather than the chart, so
|
||||
// it aggregates as a time series.
|
||||
func validateEventHeatmapQuery(requestType RequestType, functions []Function, having *Having, expressions []string, disabled bool) error {
|
||||
if err := validateBucketCountingQuery(requestType, functions, having); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if disabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Every aggregation carries its own Meta.Buckets, and a heatmap renders
|
||||
// against a single bucket axis.
|
||||
if len(expressions) != 1 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"a heatmap renders one bucket axis, but the query has %d aggregations", len(expressions)).
|
||||
WithAdditional("Keep the one aggregation whose distribution you want to plot")
|
||||
}
|
||||
|
||||
if _, ok := HeatmapAggregationField(expressions[0]); !ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"a logs or traces heatmap counts events by the field named as its aggregation, got %q", expressions[0]).
|
||||
WithAdditional("Use an aggregation such as duration_nano")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func aggregationExpressions[T LogAggregation | TraceAggregation](aggregations []T) []string {
|
||||
expressions := make([]string, len(aggregations))
|
||||
for i, aggregation := range aggregations {
|
||||
switch a := any(aggregation).(type) {
|
||||
case LogAggregation:
|
||||
expressions[i] = a.Expression
|
||||
case TraceAggregation:
|
||||
expressions[i] = a.Expression
|
||||
}
|
||||
}
|
||||
return expressions
|
||||
}
|
||||
|
||||
func GetValidationOptions(requestType RequestType) []ValidationOption {
|
||||
switch requestType {
|
||||
case RequestTypeTimeSeries, RequestTypeHeatmap, RequestTypeDistribution:
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"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"
|
||||
@@ -521,6 +527,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 +724,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 +1460,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`, `distribution`"}
|
||||
|
||||
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.
|
||||
|
||||
13
tests/e2e/drivers/antd.ts
generated
13
tests/e2e/drivers/antd.ts
generated
@@ -64,8 +64,17 @@ export function antdDriver(page: Page): Antd {
|
||||
*/
|
||||
pickByName: async (select, name) => {
|
||||
const before = await antd.selectedTags(select).count();
|
||||
await select.click();
|
||||
await expect(select).toHaveClass(/ant-select-open/);
|
||||
// A re-render of the surrounding form (e.g. after a query edit) can close
|
||||
// the select as the click opens it; re-click only while it is still closed.
|
||||
await expect(async () => {
|
||||
const isOpen = await select.evaluate((node) =>
|
||||
node.classList.contains('ant-select-open'),
|
||||
);
|
||||
if (!isOpen) {
|
||||
await select.click();
|
||||
}
|
||||
await expect(select).toHaveClass(/ant-select-open/, { timeout: 2_000 });
|
||||
}).toPass({ timeout: 15_000 });
|
||||
|
||||
// `fill` rather than `keyboard.type`: every keystroke re-runs antd's
|
||||
// filter, so typing a ~30 character name costs seconds per pick, while
|
||||
|
||||
261
tests/e2e/drivers/query-builder.ts
generated
Normal file
261
tests/e2e/drivers/query-builder.ts
generated
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Request,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { watchConsole } from './console';
|
||||
import { isQueryRange } from './query-range-mock';
|
||||
import { dismissQuickFiltersAnnouncement } from './logs/explorer';
|
||||
|
||||
import { driver, kit } from './kit';
|
||||
import { EXPLORER, RELATIVE_TIME } from './query-builder/constants';
|
||||
import {
|
||||
addOnsAt,
|
||||
exactly,
|
||||
metricAt,
|
||||
openSelect,
|
||||
} from './query-builder/controls';
|
||||
import { afterRender, editorAt } from './query-builder/editor';
|
||||
import type { QueryBuilderDriver, V5Body } from './query-builder/types';
|
||||
|
||||
function queryBuilderDriver(page: Page): QueryBuilderDriver {
|
||||
const bodies: V5Body[] = [];
|
||||
page.on('request', (request: Request) => {
|
||||
if (isQueryRange(request.url()) && request.method() === 'POST') {
|
||||
bodies.push(request.postDataJSON() as V5Body);
|
||||
}
|
||||
});
|
||||
const consoleWatch = watchConsole(page, {
|
||||
// ANTLR reports every partial expression on console.error, prefixed
|
||||
// with its position (`line 1:14 ...`).
|
||||
ignore: ['line 1:', 'line 2:', 'line 3:'],
|
||||
});
|
||||
|
||||
// Waits on a request the action started: a response to one already in
|
||||
// flight (e.g. after a structural edit) would end the wait too early.
|
||||
const firedBy = async (action: () => Promise<void>): Promise<V5Body[]> => {
|
||||
const before = bodies.length;
|
||||
const answered = page
|
||||
.waitForRequest((r) => isQueryRange(r.url()) && r.method() === 'POST')
|
||||
.then((request) => request.response());
|
||||
await action();
|
||||
await answered;
|
||||
return bodies.slice(before);
|
||||
};
|
||||
|
||||
const qb: QueryBuilderDriver = {
|
||||
async openExplorer(path, { query, raw, view } = {}) {
|
||||
await dismissQuickFiltersAnnouncement(page);
|
||||
const params = [`relativeTime=${RELATIVE_TIME}`];
|
||||
if (view) {
|
||||
params.push(`panelTypes=${encodeURIComponent(JSON.stringify(view))}`);
|
||||
}
|
||||
const composite =
|
||||
raw ??
|
||||
(query === undefined
|
||||
? undefined
|
||||
: encodeURIComponent(JSON.stringify(query)));
|
||||
if (composite !== undefined) {
|
||||
params.push(`compositeQuery=${composite}`);
|
||||
}
|
||||
const url = `${path}?${params.join('&')}`;
|
||||
// The metrics explorer runs nothing until a metric is chosen; its
|
||||
// settle point is the metric list instead.
|
||||
if (path === EXPLORER.metrics) {
|
||||
await Promise.all([
|
||||
page.waitForResponse((r) => r.url().includes('/api/v2/metrics')),
|
||||
page.goto(url),
|
||||
]);
|
||||
await expect(qb.query('A').filter.content()).toBeVisible();
|
||||
return [];
|
||||
}
|
||||
const fired = await firedBy(async () => {
|
||||
await page.goto(url);
|
||||
});
|
||||
await expect(qb.query('A').filter.content()).toBeVisible();
|
||||
return fired;
|
||||
},
|
||||
query(name) {
|
||||
const root = (): Locator => page.getByTestId(`query-${name}`);
|
||||
// Index-suffixed testids follow screen position, not the query's
|
||||
// name (A, C, B after a delete and re-add): match them in the block.
|
||||
const signalSelect = (): Locator =>
|
||||
root().locator('[data-testid^="query-data-source-selector-"]');
|
||||
return {
|
||||
root,
|
||||
filter: editorAt(page, () =>
|
||||
root().getByTestId('query-where-clause-editor'),
|
||||
),
|
||||
filterStatus: () => root().getByTestId('query-validation-status'),
|
||||
aggregation: editorAt(page, () =>
|
||||
root().getByTestId('query-aggregation-editor'),
|
||||
),
|
||||
step: () => root().getByTestId('input-Seconds'),
|
||||
metric: metricAt(page, root),
|
||||
addOns: addOnsAt(page, root),
|
||||
actions: () => root().getByTestId('query-actions'),
|
||||
menuItem: (action) =>
|
||||
page.getByTestId(`query-actions-item-${action}-query`),
|
||||
async clone() {
|
||||
await root().getByTestId('query-actions').click();
|
||||
await page.getByTestId('query-actions-item-clone-query').click();
|
||||
await afterRender(page);
|
||||
},
|
||||
async delete() {
|
||||
await root().getByTestId('query-actions').click();
|
||||
await page.getByTestId('query-actions-item-delete-query').click();
|
||||
await expect(root()).toHaveCount(0);
|
||||
await afterRender(page);
|
||||
},
|
||||
async toggleVisibility() {
|
||||
await root().getByTestId('qb-entity-visibility').click();
|
||||
// The icon is the only visible change and carries no state.
|
||||
await afterRender(page);
|
||||
},
|
||||
collapse: () => root().getByTestId('qb-entity-collapse').click(),
|
||||
signal: () => signalSelect().locator('.ant-select-selection-item'),
|
||||
async setSignal(label) {
|
||||
const options = await openSelect(page, signalSelect());
|
||||
await options.filter({ hasText: exactly(label) }).click();
|
||||
await expect(this.signal()).toHaveText(label);
|
||||
},
|
||||
};
|
||||
},
|
||||
tab: (queryType) => page.getByTestId(`query-type-tab-${queryType}`),
|
||||
promql: () => page.getByTestId('promql-query-input').first(),
|
||||
async addQuery(name) {
|
||||
await page.getByTestId('add-new-query-button').click();
|
||||
const block = qb.query(name);
|
||||
await expect(block.filter.content()).toBeVisible();
|
||||
return block;
|
||||
},
|
||||
queryNames: () =>
|
||||
page.locator('[data-testid^="query-"]').evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((node) => node.getAttribute('data-testid') ?? '')
|
||||
.filter((id) => /^query-[A-Z]$/.test(id))
|
||||
.map((id) => id.slice('query-'.length)),
|
||||
),
|
||||
formula(name) {
|
||||
const root = (): Locator => page.getByTestId(`formula-${name}`);
|
||||
return {
|
||||
root,
|
||||
expression: () => root().getByTestId('formula-expression'),
|
||||
legend: () => root().getByTestId('formula-legend'),
|
||||
async delete() {
|
||||
await root().getByTestId('qb-entity-delete').click();
|
||||
await expect(root()).toHaveCount(0);
|
||||
// The block leaves the DOM before the editor's draft follows.
|
||||
await afterRender(page);
|
||||
},
|
||||
};
|
||||
},
|
||||
async addFormula(name) {
|
||||
await page.getByTestId('add-formula-button').click();
|
||||
const formula = qb.formula(name);
|
||||
await expect(formula.expression()).toBeVisible();
|
||||
return formula;
|
||||
},
|
||||
traceOperator: {
|
||||
root: () => page.getByTestId('trace-operator'),
|
||||
// No testid; the label is followed by a "Beta" tag inside the button.
|
||||
addButton: () =>
|
||||
page.getByRole('button').filter({ hasText: /^Add Trace Matching/ }),
|
||||
async add() {
|
||||
await qb.traceOperator.addButton().click();
|
||||
await expect(qb.traceOperator.editor.content()).toBeVisible();
|
||||
},
|
||||
async remove() {
|
||||
// The only direct button child: the trash icon, no testid.
|
||||
await qb.traceOperator.root().locator(':scope > button').click();
|
||||
await expect(qb.traceOperator.root()).toHaveCount(0);
|
||||
},
|
||||
editor: editorAt(page, () => page.getByTestId('trace-operator-editor')),
|
||||
},
|
||||
async addToDashboard(title) {
|
||||
await page.getByTestId('explorer-add-to-dashboard').click();
|
||||
const dialog = page.getByTestId('export-panel-dialog');
|
||||
await dialog
|
||||
.getByTestId('export-dashboard-select')
|
||||
.locator('input')
|
||||
.fill(title);
|
||||
await dialog.locator(`.ant-select-item-option[title="${title}"]`).click();
|
||||
await dialog.getByTestId('export-panel-export').click();
|
||||
await page.waitForURL(/\/panel\/new\?/);
|
||||
await expect(qb.query('A').filter.content()).toBeVisible();
|
||||
},
|
||||
runButton: () => page.locator('[data-run-query-btn]').first(),
|
||||
// No testid on the container.
|
||||
logsResults: () => page.locator('.logs-explorer-views'),
|
||||
async run() {
|
||||
await afterRender(page);
|
||||
return firedBy(() => qb.runButton().click());
|
||||
},
|
||||
async rerunUntil(read, expected) {
|
||||
await expect(async () => {
|
||||
await expect(qb.runButton()).toHaveText(/Run Query/);
|
||||
await afterRender(page);
|
||||
await qb.runButton().click();
|
||||
await expect.poll(read, { timeout: 3_000 }).toEqual(expected);
|
||||
}).toPass({ timeout: 25_000 });
|
||||
},
|
||||
async runShortcut() {
|
||||
await afterRender(page);
|
||||
return firedBy(() => page.keyboard.press('ControlOrMeta+Enter'));
|
||||
},
|
||||
quickFilter(title, value) {
|
||||
const checkbox = (): Locator =>
|
||||
page.getByTestId(`checkbox-${title}-${value}`);
|
||||
return {
|
||||
checkbox,
|
||||
toggle: () => firedBy(() => checkbox().click()),
|
||||
};
|
||||
},
|
||||
spec(fired, name) {
|
||||
for (const body of fired) {
|
||||
const match = body.compositeQuery?.queries?.find(
|
||||
(q) => q.spec?.name === name,
|
||||
);
|
||||
if (match) {
|
||||
return match.spec;
|
||||
}
|
||||
}
|
||||
throw new Error(`no query ${name} in ${fired.length} query_range bodies`);
|
||||
},
|
||||
sent: () => bodies,
|
||||
latest: (name) =>
|
||||
[...bodies]
|
||||
.reverse()
|
||||
.flatMap((body) => body.compositeQuery?.queries ?? [])
|
||||
.find((query) => query.spec?.name === name)?.spec,
|
||||
urlQuery() {
|
||||
const raw = new URL(page.url()).searchParams.get('compositeQuery');
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
// The app encodes before URLSearchParams encodes again.
|
||||
return JSON.parse(decodeURIComponent(raw)) as Record<string, unknown>;
|
||||
},
|
||||
console: consoleWatch,
|
||||
};
|
||||
return qb;
|
||||
}
|
||||
|
||||
/** Layers `qb` onto another host's test, e.g. the alert-rule fixtures. */
|
||||
export const withQueryBuilder = driver('qb', ({ page }) =>
|
||||
queryBuilderDriver(page),
|
||||
);
|
||||
|
||||
export const test = withQueryBuilder(kit);
|
||||
|
||||
export { expect } from './kit';
|
||||
export * from './query-builder/constants';
|
||||
export * from './query-builder/types';
|
||||
export { reduceToLabel } from './query-builder/controls';
|
||||
export { withMetricTypes } from './query-builder/seed';
|
||||
17
tests/e2e/drivers/query-builder/constants.ts
generated
Normal file
17
tests/e2e/drivers/query-builder/constants.ts
generated
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
export const EXPLORER = {
|
||||
logs: '/logs/logs-explorer',
|
||||
traces: '/traces-explorer',
|
||||
metrics: '/metrics-explorer/explorer',
|
||||
} as const;
|
||||
|
||||
/** A monotonic Sum in the golden dataset. */
|
||||
export const SUM_METRIC = 'signoz_calls_total';
|
||||
/** Another golden Sum, for switching between metrics of one type. */
|
||||
export const OTHER_SUM_METRIC = 'signoz_latency_count';
|
||||
|
||||
/** Inside the golden dataset's window. */
|
||||
export const RELATIVE_TIME = '6h';
|
||||
136
tests/e2e/drivers/query-builder/controls.ts
generated
Normal file
136
tests/e2e/drivers/query-builder/controls.ts
generated
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { antdDriver } from '../antd';
|
||||
|
||||
import { editorAt } from './editor';
|
||||
import type { AddOnField, AddOns, MetricControls } from './types';
|
||||
|
||||
/**
|
||||
* Opens the antd select inside `select` and returns its options. Scoped to the
|
||||
* list the select owns: a previous select's list can still be mid-close.
|
||||
*/
|
||||
export async function openSelect(
|
||||
page: Page,
|
||||
select: Locator,
|
||||
): Promise<Locator> {
|
||||
await select.locator('.ant-select-selector').click();
|
||||
const list = await antdDriver(page).ownDropdown(select);
|
||||
await expect(list).toBeVisible();
|
||||
return list.locator('.ant-select-item-option');
|
||||
}
|
||||
|
||||
/** antd renders select options with trailing whitespace ("Sum "). */
|
||||
export const exactly = (label: string): RegExp =>
|
||||
new RegExp(`^\\s*${label}\\s*$`);
|
||||
|
||||
/** A reduce-to option or value by its operator: "Sum of values in timeframe". */
|
||||
export const reduceToLabel = (operator: string): RegExp =>
|
||||
new RegExp(`^\\s*${operator} of values`);
|
||||
|
||||
const ADD_ON_CONTENT: Record<AddOnField, string> = {
|
||||
group_by: 'group-by-content',
|
||||
having: 'having-content',
|
||||
order_by: 'order-by-content',
|
||||
limit: 'limit-content',
|
||||
legend_format: 'legend-format-content',
|
||||
reduce_to: 'reduce-to-content',
|
||||
};
|
||||
|
||||
export function addOnsAt(page: Page, root: () => Locator): AddOns {
|
||||
const content = (field: AddOnField): Locator =>
|
||||
root().getByTestId(ADD_ON_CONTENT[field]);
|
||||
const groupBy = (): Locator => root().getByTestId('group-by');
|
||||
const addOns: AddOns = {
|
||||
toggles: () => root().locator('[data-testid^="query-add-on-"]'),
|
||||
async open(field) {
|
||||
if (await content(field).isVisible()) {
|
||||
return;
|
||||
}
|
||||
await root().getByTestId(`query-add-on-${field}`).click();
|
||||
await expect(content(field)).toBeVisible();
|
||||
},
|
||||
content,
|
||||
groupBy: {
|
||||
async add(key) {
|
||||
const options = await openSelect(page, groupBy());
|
||||
await groupBy().locator('input').fill(key);
|
||||
await options
|
||||
.filter({ hasText: exactly(key) })
|
||||
.first()
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(
|
||||
groupBy().locator('.ant-select-selection-item', { hasText: key }),
|
||||
).toBeVisible();
|
||||
},
|
||||
async remove(key) {
|
||||
await groupBy()
|
||||
.locator('.ant-select-selection-item', { hasText: key })
|
||||
.locator('.ant-select-selection-item-remove')
|
||||
.click();
|
||||
},
|
||||
async values() {
|
||||
const texts = await groupBy()
|
||||
.locator('.ant-select-selection-item-content')
|
||||
.allTextContents();
|
||||
return texts.map((text) => text.trim());
|
||||
},
|
||||
},
|
||||
having: editorAt(page, () => root().getByTestId('having-editor')),
|
||||
limit: () => content('limit').getByTestId('input-Limit'),
|
||||
legend: () => content('legend_format').getByTestId('input-Legend format'),
|
||||
reduceTo: {
|
||||
value: () =>
|
||||
root().getByTestId('reduce-to').locator('.ant-select-selection-item'),
|
||||
// Labels read "<Op> of values in timeframe"; matched on the operator.
|
||||
async set(label) {
|
||||
const options = await openSelect(page, root().getByTestId('reduce-to'));
|
||||
await options.filter({ hasText: reduceToLabel(label) }).click();
|
||||
await expect(addOns.reduceTo.value()).toHaveText(reduceToLabel(label));
|
||||
},
|
||||
},
|
||||
};
|
||||
return addOns;
|
||||
}
|
||||
|
||||
export function metricAt(page: Page, root: () => Locator): MetricControls {
|
||||
const picker = (): Locator =>
|
||||
root().locator('[data-testid^="metric-name-selector-"]');
|
||||
const time = (): Locator => root().getByTestId('metrics-time-aggregation');
|
||||
const space = (): Locator => root().getByTestId('metrics-space-aggregation');
|
||||
return {
|
||||
async select(name) {
|
||||
// An antd AutoComplete: the testid is on a wrapper.
|
||||
const field = picker();
|
||||
const options = await openSelect(page, field);
|
||||
await field.locator('input').fill(name);
|
||||
// Options carry the metric's type and unit next to its name.
|
||||
await options.filter({ hasText: name }).first().click();
|
||||
},
|
||||
// The selection item, not the root: these selects render their list
|
||||
// inside the root, so its text includes every option.
|
||||
input: () => picker().locator('input'),
|
||||
timeAggregation: () => time().locator('.ant-select-selection-item'),
|
||||
spaceAggregation: () => space().locator('.ant-select-selection-item'),
|
||||
async setTimeAggregation(label) {
|
||||
const options = await openSelect(page, time());
|
||||
await options.filter({ hasText: exactly(label) }).click();
|
||||
await expect(this.timeAggregation()).toHaveText(label);
|
||||
},
|
||||
async setSpaceAggregation(label) {
|
||||
const options = await openSelect(page, space());
|
||||
await options.filter({ hasText: exactly(label) }).click();
|
||||
await expect(this.spaceAggregation()).toHaveText(label);
|
||||
},
|
||||
async spaceOptions() {
|
||||
const options = await openSelect(page, space());
|
||||
const labels = await options.allTextContents();
|
||||
await page.keyboard.press('Escape');
|
||||
return labels.map((label) => label.trim());
|
||||
},
|
||||
};
|
||||
}
|
||||
77
tests/e2e/drivers/query-builder/editor.ts
generated
Normal file
77
tests/e2e/drivers/query-builder/editor.ts
generated
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import type { FilterEditor } from './types';
|
||||
|
||||
/** CodeMirror renders its placeholder inside the empty line. */
|
||||
const EDITOR_DOC = async (root: Locator): Promise<string> => {
|
||||
const lines = root.locator('.cm-content .cm-line');
|
||||
const texts = await lines.allTextContents();
|
||||
const placeholders = await lines.evaluateAll((nodes) =>
|
||||
nodes.map((node) => node.querySelector('.cm-placeholder')?.textContent ?? ''),
|
||||
);
|
||||
return texts
|
||||
.map((text, i) =>
|
||||
placeholders[i] ? text.replace(placeholders[i], '') : text,
|
||||
)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
export function editorAt(page: Page, root: () => Locator): FilterEditor {
|
||||
const content = (): Locator => root().locator('.cm-content');
|
||||
const editor: FilterEditor = {
|
||||
root,
|
||||
content,
|
||||
async type(text) {
|
||||
await editor.clear();
|
||||
await page.keyboard.type(text);
|
||||
},
|
||||
async append(text) {
|
||||
await content().click();
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.type(text);
|
||||
},
|
||||
async clear() {
|
||||
await content().click();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await page.keyboard.press('Backspace');
|
||||
await expect.poll(() => EDITOR_DOC(root())).toBe('');
|
||||
},
|
||||
press: (key) => page.keyboard.press(key),
|
||||
expression: () => EDITOR_DOC(root()),
|
||||
lineCount: () => root().locator('.cm-line'),
|
||||
suggestions: () => page.locator('.cm-tooltip-autocomplete'),
|
||||
async openSuggestions() {
|
||||
await expect(async () => {
|
||||
await page.keyboard.press('Control+Space');
|
||||
await expect(editor.suggestions()).toBeVisible({ timeout: 2_000 });
|
||||
}).toPass({ timeout: 15_000 });
|
||||
},
|
||||
suggestion: (label) =>
|
||||
page
|
||||
.locator('.cm-tooltip-autocomplete [role="option"]')
|
||||
.filter({ hasText: label }),
|
||||
};
|
||||
return editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two frames: an edit reaches React state on its next scheduler task, and an
|
||||
* action in the same frame reads the previous render. A long render delays
|
||||
* the frame itself, so this cannot pass before the commit.
|
||||
*/
|
||||
export async function afterRender(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
// Runs in the browser; the e2e tsconfig has no DOM lib.
|
||||
const { requestAnimationFrame: frame } = globalThis as unknown as {
|
||||
requestAnimationFrame(this: void, callback: () => void): number;
|
||||
};
|
||||
frame(() => frame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
}
|
||||
50
tests/e2e/drivers/query-builder/seed.ts
generated
Normal file
50
tests/e2e/drivers/query-builder/seed.ts
generated
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { postToSeeder } from '../telemetry';
|
||||
|
||||
import { seeded } from '../kit';
|
||||
|
||||
import type { MetricTypes } from './types';
|
||||
|
||||
const HISTOGRAM_BOUNDS = ['1', '2', '4', '+Inf'];
|
||||
|
||||
/** Ten minutes of a gauge and a cumulative histogram, names unique per worker. */
|
||||
export const withMetricTypes = seeded(
|
||||
'metricTypes',
|
||||
async ({ page, workerIndex }) => {
|
||||
const suffix = `${workerIndex}_${Date.now()}`;
|
||||
const types: MetricTypes = {
|
||||
gauge: `e2e_qb_gauge_${suffix}`,
|
||||
histogram: `e2e_qb_histogram_${suffix}`,
|
||||
};
|
||||
const now = Date.now();
|
||||
const points: Record<string, unknown>[] = [];
|
||||
for (let minute = 0; minute < 10; minute += 1) {
|
||||
const timestamp = new Date(now - (10 - minute) * 60_000).toISOString();
|
||||
points.push({
|
||||
metric_name: types.gauge,
|
||||
labels: { host: 'qb-host' },
|
||||
timestamp,
|
||||
value: 10 + minute,
|
||||
type_: 'Gauge',
|
||||
temporality: 'Unspecified',
|
||||
is_monotonic: false,
|
||||
});
|
||||
HISTOGRAM_BOUNDS.forEach((le, bucket) => {
|
||||
points.push({
|
||||
metric_name: types.histogram,
|
||||
labels: { le },
|
||||
timestamp,
|
||||
value: (minute + 1) * (bucket + 1),
|
||||
type_: 'Histogram',
|
||||
temporality: 'Cumulative',
|
||||
is_monotonic: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
await postToSeeder(page.request, '/telemetry/metrics', points);
|
||||
return types;
|
||||
},
|
||||
);
|
||||
225
tests/e2e/drivers/query-builder/types.ts
generated
Normal file
225
tests/e2e/drivers/query-builder/types.ts
generated
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import type { Locator } from '@playwright/test';
|
||||
|
||||
import type { ConsoleWatch } from '../console';
|
||||
|
||||
/** The golden dataset has only Sums; these are seeded per worker. */
|
||||
export interface MetricTypes {
|
||||
gauge: string;
|
||||
histogram: string;
|
||||
}
|
||||
|
||||
/** One entry of a v5 `query_range` body's `compositeQuery.queries`. */
|
||||
export interface V5Query {
|
||||
type: string;
|
||||
spec: {
|
||||
name?: string;
|
||||
signal?: string;
|
||||
disabled?: boolean;
|
||||
filter?: { expression?: string };
|
||||
aggregations?: {
|
||||
expression?: string;
|
||||
metricName?: string;
|
||||
timeAggregation?: string;
|
||||
spaceAggregation?: string;
|
||||
reduceTo?: string;
|
||||
}[];
|
||||
groupBy?: { name: string }[];
|
||||
having?: { expression?: string };
|
||||
order?: { key: { name: string }; direction: string }[];
|
||||
limit?: number;
|
||||
stepInterval?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface V5Body {
|
||||
requestType: string;
|
||||
compositeQuery: { queries: V5Query[] };
|
||||
}
|
||||
|
||||
export interface FilterEditor {
|
||||
root(): Locator;
|
||||
/** The contenteditable CodeMirror surface. */
|
||||
content(): Locator;
|
||||
/** Replaces the expression by typing it key by key. */
|
||||
type(text: string): Promise<void>;
|
||||
/** Appends at the caret without clearing. */
|
||||
append(text: string): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
press(key: string): Promise<void>;
|
||||
/** The document text, placeholder excluded, lines joined with `\n`. */
|
||||
expression(): Promise<string>;
|
||||
lineCount(): Locator;
|
||||
/** The open autocomplete popup. */
|
||||
suggestions(): Locator;
|
||||
/**
|
||||
* Ctrl+Space: opens the popup at the caret. Typing opens it only once the
|
||||
* debounced key fetch answers, which a loaded stack can delay past a test's
|
||||
* next step.
|
||||
*/
|
||||
openSuggestions(): Promise<void>;
|
||||
suggestion(label: string | RegExp): Locator;
|
||||
}
|
||||
|
||||
export interface QueryBlock {
|
||||
root(): Locator;
|
||||
filter: FilterEditor;
|
||||
/** The filter's status icon: shown only while unfocused and non-empty. */
|
||||
filterStatus(): Locator;
|
||||
aggregation: FilterEditor;
|
||||
/** The aggregation's "every … Seconds" input. */
|
||||
step(): Locator;
|
||||
metric: MetricControls;
|
||||
addOns: AddOns;
|
||||
/** The "…" menu: offered only where several queries are allowed. */
|
||||
actions(): Locator;
|
||||
/** A row of the open "…" menu. */
|
||||
menuItem(action: 'clone' | 'delete'): Locator;
|
||||
clone(): Promise<void>;
|
||||
delete(): Promise<void>;
|
||||
toggleVisibility(): Promise<void>;
|
||||
collapse(): Promise<void>;
|
||||
/** The data-source dropdown's value; only on hosts that let it change. */
|
||||
signal(): Locator;
|
||||
setSignal(label: 'Logs' | 'Traces' | 'Metrics'): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FormulaBlock {
|
||||
root(): Locator;
|
||||
expression(): Locator;
|
||||
legend(): Locator;
|
||||
delete(): Promise<void>;
|
||||
}
|
||||
|
||||
/** `query-add-on-<field>` toggle values: a documented DOM contract. */
|
||||
export type AddOnField =
|
||||
| 'group_by'
|
||||
| 'having'
|
||||
| 'order_by'
|
||||
| 'limit'
|
||||
| 'legend_format'
|
||||
| 'reduce_to';
|
||||
|
||||
export interface AddOns {
|
||||
/** The toggle row; its children name the add-ons on offer. */
|
||||
toggles(): Locator;
|
||||
/** Idempotent: leaves an already-open add-on open. */
|
||||
open(field: AddOnField): Promise<void>;
|
||||
content(field: AddOnField): Locator;
|
||||
groupBy: {
|
||||
/** Types `key` and picks the suggestion with that exact name. */
|
||||
add(key: string): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
/** The selected keys, in order. */
|
||||
values(): Promise<string[]>;
|
||||
};
|
||||
having: FilterEditor;
|
||||
limit(): Locator;
|
||||
legend(): Locator;
|
||||
reduceTo: {
|
||||
/** The selected value. */
|
||||
value(): Locator;
|
||||
set(label: string): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
/** The metrics aggregation section: metric picker plus time/space selects. */
|
||||
export interface MetricControls {
|
||||
select(name: string): Promise<void>;
|
||||
/** The metric name input. */
|
||||
input(): Locator;
|
||||
/** The selected value. Absent for histograms. */
|
||||
timeAggregation(): Locator;
|
||||
/** The selected value. */
|
||||
spaceAggregation(): Locator;
|
||||
setTimeAggregation(label: string): Promise<void>;
|
||||
setSpaceAggregation(label: string): Promise<void>;
|
||||
/** Labels offered by the space-aggregation select, as shown. */
|
||||
spaceOptions(): Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface QuickFilterValue {
|
||||
checkbox(): Locator;
|
||||
/** Toggles the value; returns the query_range bodies the toggle fired. */
|
||||
toggle(): Promise<V5Body[]>;
|
||||
}
|
||||
|
||||
/** The explorer's tab, carried in the `panelTypes` param. */
|
||||
export type ExplorerView = 'list' | 'graph' | 'table';
|
||||
|
||||
export interface OpenOptions {
|
||||
/** Serialised the way the app writes it. */
|
||||
query?: unknown;
|
||||
/** A `compositeQuery` value sent as given, for malformed input. */
|
||||
raw?: string;
|
||||
view?: ExplorerView;
|
||||
}
|
||||
|
||||
export interface QueryBuilderDriver {
|
||||
/**
|
||||
* Resolves once the page's own first query_range has answered: the explorer
|
||||
* rewrites its URL and re-mounts the builder until then, which drops
|
||||
* keystrokes. Returns the bodies that load fired.
|
||||
*/
|
||||
openExplorer(path: string, options?: OpenOptions): Promise<V5Body[]>;
|
||||
query(name: string): QueryBlock;
|
||||
/** Adds the next query (B, C, …) and waits for its block. */
|
||||
addQuery(name: string): Promise<QueryBlock>;
|
||||
/** Builder query names, top to bottom. */
|
||||
queryNames(): Promise<string[]>;
|
||||
/** Panel-editor query-type tab, by its queryType (`builder`, `promql`, …). */
|
||||
tab(queryType: string): Locator;
|
||||
promql(): Locator;
|
||||
formula(name: string): FormulaBlock;
|
||||
addFormula(name: string): Promise<FormulaBlock>;
|
||||
traceOperator: {
|
||||
root(): Locator;
|
||||
/** "Add Trace Matching": offered on traces hosts that allow it. */
|
||||
addButton(): Locator;
|
||||
add(): Promise<void>;
|
||||
remove(): Promise<void>;
|
||||
editor: FilterEditor;
|
||||
};
|
||||
/**
|
||||
* Explorer "Add to dashboard": picks the dashboard titled `title` and waits
|
||||
* for the new-panel editor the export navigates to.
|
||||
*/
|
||||
addToDashboard(title: string): Promise<void>;
|
||||
runButton(): Locator;
|
||||
/** The logs explorer's results area, below the builder. */
|
||||
logsResults(): Locator;
|
||||
/** Clicks Run; returns every query_range body the click fired. */
|
||||
run(): Promise<V5Body[]>;
|
||||
/**
|
||||
* Clicks Run until `read()` equals `expected`. For panel-editor structural
|
||||
* edits, whose draft can lag the DOM: an early Run is a cache hit that
|
||||
* sends nothing. Never clicks while the button is Cancel.
|
||||
*/
|
||||
rerunUntil(read: () => unknown, expected: unknown): Promise<void>;
|
||||
/**
|
||||
* Ctrl/Cmd+Enter from the focused field; returns the fired bodies. Waits two
|
||||
* frames first: editor changes reach React state on its next scheduler task,
|
||||
* and a keypress inside the same frame as the last keystroke reads the
|
||||
* previous render — a gap no person can produce.
|
||||
*/
|
||||
runShortcut(): Promise<V5Body[]>;
|
||||
/** A value in an explorer's checkbox quick filter, by its header title. */
|
||||
quickFilter(title: string, value: string): QuickFilterValue;
|
||||
/** The spec of query `name` in the first body that has it. */
|
||||
spec(bodies: V5Body[], name: string): V5Query['spec'];
|
||||
/** Every query_range body sent since the test started. */
|
||||
sent(): V5Body[];
|
||||
/**
|
||||
* Query `name`'s spec in the newest body that has it, whatever sent it. The
|
||||
* panel editor fetches on structural edits, so Run after one can be a cache
|
||||
* hit that sends nothing: poll this rather than waiting on Run.
|
||||
*/
|
||||
latest(name: string): V5Query['spec'] | undefined;
|
||||
/** The URL's `compositeQuery`, parsed. */
|
||||
urlQuery(): Record<string, unknown> | null;
|
||||
console: ConsoleWatch;
|
||||
}
|
||||
4
tests/e2e/drivers/query-range-mock.ts
generated
4
tests/e2e/drivers/query-range-mock.ts
generated
@@ -272,7 +272,9 @@ export async function holdQueryRange(
|
||||
});
|
||||
await page.route(QUERY_RANGE_GLOB, async (route) => {
|
||||
await gate;
|
||||
await fulfill(route, body);
|
||||
// A cancelled run aborts its request while it is held; there is
|
||||
// nothing left to answer.
|
||||
await fulfill(route, body).catch(() => undefined);
|
||||
});
|
||||
return { release: () => release() };
|
||||
}
|
||||
|
||||
481
tests/e2e/drivers/saved-views.ts
generated
Normal file
481
tests/e2e/drivers/saved-views.ts
generated
Normal file
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Request,
|
||||
type Response,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { BASE_PATH } from './base-path';
|
||||
import { collectRequests } from './console';
|
||||
import { dismissQuickFiltersAnnouncement } from './logs/explorer';
|
||||
import {
|
||||
filterExpressions,
|
||||
isQueryRangeRequest,
|
||||
queryRangeBody,
|
||||
} from './saved-views/query';
|
||||
import { SAVED_VIEWS_API, seededView } from './saved-views/spec';
|
||||
import type {
|
||||
GettableSavedView,
|
||||
PostableSavedView,
|
||||
SavedViewSource,
|
||||
SeededView,
|
||||
} from './saved-views/spec.types';
|
||||
|
||||
import { driver, kit } from './kit';
|
||||
import {
|
||||
createViaApi,
|
||||
getViaApi,
|
||||
removeTrackedSavedViews,
|
||||
statusViaApi,
|
||||
trackSavedView,
|
||||
} from './saved-views/api';
|
||||
|
||||
// The saved views header and list on the explorers: the sliding list on logs,
|
||||
// traces and meter, the pinned one on metrics.
|
||||
|
||||
export const MY_VIEW = 'My view';
|
||||
|
||||
const EXPLORER_PATH: Record<SavedViewSource, string> = {
|
||||
logs: '/logs/logs-explorer',
|
||||
traces: '/traces-explorer',
|
||||
metrics: '/metrics-explorer/explorer',
|
||||
meter: '/meter/explorer',
|
||||
};
|
||||
|
||||
export function explorerPathname(source: SavedViewSource): string {
|
||||
return `${BASE_PATH}${EXPLORER_PATH[source]}`;
|
||||
}
|
||||
|
||||
/** Row-menu items by the suffix of their `saved-views-row-menu-item-<key>` test id. */
|
||||
export type RowAction = 'copyLink' | 'copyId' | 'editDetails' | 'delete';
|
||||
|
||||
/** Save-menu items by the suffix of their `saved-views-save-changes-item-<key>` test id. */
|
||||
export type SaveAction = 'update' | 'save-as-new';
|
||||
|
||||
export type LinkGesture = 'modifier' | 'middle';
|
||||
|
||||
export interface OpenedTab {
|
||||
page: Page;
|
||||
savedViews: SavedViews;
|
||||
}
|
||||
|
||||
export type ExplorerTab = 'list' | 'timeseries' | 'table';
|
||||
|
||||
export interface SavedViews {
|
||||
/** Created over the page's own session; deleted when the test ends. */
|
||||
seed(view: PostableSavedView): Promise<SeededView>;
|
||||
/** The stored view, read straight from the API. */
|
||||
get(id: string): Promise<GettableSavedView>;
|
||||
/** The status of `GET /api/v2/saved_views/<id>`, for asserting a view is gone. */
|
||||
status(id: string): Promise<number>;
|
||||
|
||||
/** On a bare url by default, which is `relativeTime=1h` and nothing else. */
|
||||
openExplorer(
|
||||
source?: SavedViewSource,
|
||||
params?: Record<string, string>,
|
||||
): Promise<void>;
|
||||
/** Reloads and waits for the explorer to send the filter's query. */
|
||||
reload(filter?: string, requestType?: string): Promise<void>;
|
||||
/** The explorer's next query that carries `filter`, of `requestType` when given. */
|
||||
queryRan(filter: string, requestType?: string): Promise<Response>;
|
||||
/** The current url. */
|
||||
url(): URL;
|
||||
/** A query param of the current url, where the open view and time range live. */
|
||||
param(name: string): string | null;
|
||||
/** The id in the `viewKey` param, or null while no view is open. */
|
||||
activeViewId(): string | null;
|
||||
|
||||
header(): Locator;
|
||||
name(): Locator;
|
||||
createButton(): Locator;
|
||||
clearButton(): Locator;
|
||||
discardButton(): Locator;
|
||||
saveChangesButton(): Locator;
|
||||
saveAction(action: SaveAction): Locator;
|
||||
/** The view is open and nothing differs from what is stored. */
|
||||
expectClean(displayName: string): Promise<void>;
|
||||
/** The view is open and the explorer holds changes to it. */
|
||||
expectDirty(displayName: string): Promise<void>;
|
||||
/** No view is open. */
|
||||
expectNoView(): Promise<void>;
|
||||
toast(text: string): Locator;
|
||||
|
||||
openListButton(): Locator;
|
||||
panel(): Locator;
|
||||
searchInput(): Locator;
|
||||
openList(): Promise<void>;
|
||||
/** Narrows the list, and waits for exactly `rows` rows when given. */
|
||||
search(text: string, rows?: number): Promise<void>;
|
||||
rows(): Locator;
|
||||
row(id: string): Locator;
|
||||
rowLink(id: string): Locator;
|
||||
/** The row link's href, resolved against the current page. */
|
||||
rowHref(id: string): Promise<URL>;
|
||||
/**
|
||||
* Searches for the view, opens it from its row and waits for the explorer's
|
||||
* query for it. Resolves with that query's response.
|
||||
*/
|
||||
open(view: SeededView): Promise<Response>;
|
||||
|
||||
rowMenuButton(id: string): Locator;
|
||||
rowAction(action: RowAction): Locator;
|
||||
openRowMenu(id: string): Promise<void>;
|
||||
/** Opens the row's menu and picks an action once its permission check settles. */
|
||||
chooseRowAction(id: string, action: RowAction): Promise<void>;
|
||||
/** Opens the row's menu and picks Copy link, returning the copied text. */
|
||||
copyLink(id: string): Promise<string>;
|
||||
/**
|
||||
* Ctrl or Cmd click, or a middle click, on the row link. Resolves once the
|
||||
* tab exists, not once it has loaded; the driver is bound to the new tab.
|
||||
*/
|
||||
openInNewTab(id: string, gesture?: LinkGesture): Promise<OpenedTab>;
|
||||
/**
|
||||
* Opens `url` in a new tab of the same browser context and waits for the
|
||||
* explorer's query for `view`.
|
||||
*/
|
||||
openLink(url: URL | string, view: SeededView): Promise<OpenedTab>;
|
||||
/** Tabs the browser context has opened since this driver was built. */
|
||||
newTabCount(): number;
|
||||
rename(id: string, displayName: string): Promise<void>;
|
||||
/** Confirms the delete dialog and waits for the DELETE. */
|
||||
deleteView(id: string): Promise<void>;
|
||||
|
||||
modal(): Locator;
|
||||
modalNameInput(): Locator;
|
||||
modalSubmit(): Locator;
|
||||
deleteConfirm(): Locator;
|
||||
/** Create from the header: names the view, saves it and returns its id. */
|
||||
createFromHeader(displayName: string): Promise<string>;
|
||||
/** Types into the query bar, runs it and waits for the query to carry it. */
|
||||
applyFilter(filter: string): Promise<void>;
|
||||
queryBar(): Locator;
|
||||
/** Header cells of the logs table with a title: the state indicator has none. */
|
||||
logsColumnHeaders(): Locator;
|
||||
openLogsFormatMenu(): Promise<void>;
|
||||
closeLogsFormatMenu(): Promise<void>;
|
||||
/** The checked format in the open menu: Raw, Default or Column. */
|
||||
selectedLogsFormat(): Locator;
|
||||
logsMaxLines(): Locator;
|
||||
logsFontSize(): Locator;
|
||||
explorerTab(tab: ExplorerTab): Locator;
|
||||
/** Changes when the document reloads and not when the app navigates in place. */
|
||||
documentId(): Promise<number>;
|
||||
/** Update selected view, waiting for the PUT and the toast. */
|
||||
update(): Promise<void>;
|
||||
discard(): Promise<void>;
|
||||
|
||||
/** `GET /api/v2/saved_views/<id>` requests the page has made so far. */
|
||||
viewGets(id: string): Request[];
|
||||
}
|
||||
|
||||
/** The driver on any page of the same context, such as a tab a link opened. */
|
||||
export function savedViewsFor(page: Page): SavedViews {
|
||||
const requests = collectRequests(page);
|
||||
let newTabs = 0;
|
||||
page.context().on('page', () => {
|
||||
newTabs += 1;
|
||||
});
|
||||
const tid = (id: string): Locator => page.getByTestId(id);
|
||||
const formatMenu = (): Locator => page.locator('.format-options-popover');
|
||||
const viewPath = (id: string): string =>
|
||||
`${BASE_PATH}${SAVED_VIEWS_API}/${id}`;
|
||||
|
||||
// A menu row, like a header button, ignores clicks until its permission
|
||||
// check settles, and stays enabled-looking meanwhile.
|
||||
const settle = async (control: Locator): Promise<void> => {
|
||||
await expect(control).toBeVisible();
|
||||
await expect(control).not.toHaveAttribute('aria-busy');
|
||||
await expect(control).not.toHaveAttribute('data-loading');
|
||||
await expect(control).not.toHaveAttribute('data-disabled');
|
||||
};
|
||||
const press = async (control: Locator): Promise<void> => {
|
||||
await settle(control);
|
||||
await control.click();
|
||||
};
|
||||
|
||||
const savedViews: SavedViews = {
|
||||
async seed(view) {
|
||||
const id = await createViaApi(page, view);
|
||||
trackSavedView(page, id);
|
||||
return seededView(id, view);
|
||||
},
|
||||
get: (id) => getViaApi(page, id),
|
||||
status: (id) => statusViaApi(page, id),
|
||||
|
||||
async openExplorer(source = 'logs', params = { relativeTime: '1h' }) {
|
||||
await dismissQuickFiltersAnnouncement(page);
|
||||
const search = new URLSearchParams(params).toString();
|
||||
await page.goto(`${EXPLORER_PATH[source]}${search ? `?${search}` : ''}`);
|
||||
await expect(savedViews.header()).toBeVisible();
|
||||
},
|
||||
async reload(filter, requestType) {
|
||||
await Promise.all([
|
||||
filter === undefined ? undefined : savedViews.queryRan(filter, requestType),
|
||||
page.reload(),
|
||||
]);
|
||||
await expect(savedViews.header()).toBeVisible();
|
||||
},
|
||||
queryRan: (filter, requestType) =>
|
||||
page.waitForResponse((res) => {
|
||||
const request = res.request();
|
||||
return (
|
||||
isQueryRangeRequest(request) &&
|
||||
filterExpressions(request).includes(filter) &&
|
||||
(requestType === undefined ||
|
||||
queryRangeBody(request).requestType === requestType)
|
||||
);
|
||||
}),
|
||||
url: () => new URL(page.url()),
|
||||
param: (name) => new URL(page.url()).searchParams.get(name),
|
||||
activeViewId() {
|
||||
const raw = savedViews.param('viewKey');
|
||||
return raw === null ? null : (JSON.parse(raw) as string);
|
||||
},
|
||||
|
||||
header: () => tid('saved-views-header'),
|
||||
name: () => tid('saved-views-name'),
|
||||
createButton: () => tid('saved-views-create'),
|
||||
clearButton: () => tid('saved-views-clear'),
|
||||
discardButton: () => tid('saved-views-discard'),
|
||||
saveChangesButton: () => tid('saved-views-save-changes'),
|
||||
saveAction: (action) => tid(`saved-views-save-changes-item-${action}`),
|
||||
async expectClean(displayName) {
|
||||
await expect(savedViews.name()).toHaveText(displayName);
|
||||
await expect(savedViews.clearButton()).toBeVisible();
|
||||
await expect(savedViews.discardButton()).toBeHidden();
|
||||
await expect(savedViews.saveChangesButton()).toBeHidden();
|
||||
},
|
||||
async expectDirty(displayName) {
|
||||
await expect(savedViews.name()).toHaveText(displayName);
|
||||
await expect(savedViews.discardButton()).toBeVisible();
|
||||
await expect(savedViews.saveChangesButton()).toBeVisible();
|
||||
await expect(savedViews.clearButton()).toBeHidden();
|
||||
},
|
||||
async expectNoView() {
|
||||
await expect(savedViews.name()).toHaveText(MY_VIEW);
|
||||
await expect(savedViews.createButton()).toBeVisible();
|
||||
await expect(savedViews.clearButton()).toBeHidden();
|
||||
await expect.poll(() => savedViews.param('viewKey')).toBeNull();
|
||||
},
|
||||
toast: (text) =>
|
||||
page.locator('[data-sonner-toast]').filter({ hasText: text }),
|
||||
|
||||
openListButton: () => tid('saved-views-open'),
|
||||
panel: () => tid('saved-views-panel'),
|
||||
searchInput: () => tid('saved-views-search'),
|
||||
async openList() {
|
||||
await expect(
|
||||
savedViews.openListButton().or(savedViews.panel()),
|
||||
).toBeVisible();
|
||||
if (!(await savedViews.panel().isVisible())) {
|
||||
await press(savedViews.openListButton());
|
||||
}
|
||||
await expect(savedViews.panel()).toBeVisible();
|
||||
},
|
||||
async search(text, rows) {
|
||||
await savedViews.searchInput().fill(text);
|
||||
if (rows !== undefined) {
|
||||
await expect(savedViews.rows()).toHaveCount(rows);
|
||||
}
|
||||
},
|
||||
rows: () => tid('saved-views-row'),
|
||||
row: (id) =>
|
||||
page.locator(`[data-testid="saved-views-row"][data-view-id="${id}"]`),
|
||||
rowLink: (id) => savedViews.row(id).getByTestId('saved-views-row-name'),
|
||||
async rowHref(id) {
|
||||
const href = await savedViews.rowLink(id).getAttribute('href');
|
||||
expect(href, `row ${id} has no href`).not.toBeNull();
|
||||
return new URL(href as string, page.url());
|
||||
},
|
||||
async open(view) {
|
||||
await savedViews.openList();
|
||||
await savedViews.search(view.displayName);
|
||||
const [response] = await Promise.all([
|
||||
savedViews.queryRan(view.filter, view.requestType),
|
||||
savedViews.rowLink(view.id).click(),
|
||||
]);
|
||||
await expect.poll(() => savedViews.activeViewId()).toBe(view.id);
|
||||
await expect(savedViews.name()).toHaveText(view.displayName);
|
||||
return response;
|
||||
},
|
||||
|
||||
rowMenuButton: (id) => savedViews.row(id).getByTestId('saved-views-row-menu'),
|
||||
rowAction: (action) => tid(`saved-views-row-menu-item-${action}`),
|
||||
async openRowMenu(id) {
|
||||
// The menu button has no width until the row is hovered.
|
||||
await savedViews.row(id).hover();
|
||||
await savedViews.rowMenuButton(id).click();
|
||||
},
|
||||
async chooseRowAction(id, action) {
|
||||
await savedViews.openRowMenu(id);
|
||||
await press(savedViews.rowAction(action));
|
||||
},
|
||||
async copyLink(id) {
|
||||
// Clipboard permissions are Chromium-only, so the copy is caught in the
|
||||
// page: react-use copies through a selection and execCommand, whose
|
||||
// `copy` event a capture listener sees first.
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as { __copied?: string };
|
||||
delete w.__copied;
|
||||
document.addEventListener(
|
||||
'copy',
|
||||
() => {
|
||||
w.__copied = document.getSelection()?.toString() ?? '';
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
});
|
||||
await savedViews.chooseRowAction(id, 'copyLink');
|
||||
const copied = (): Promise<string | undefined> =>
|
||||
page.evaluate(() => (window as unknown as { __copied?: string }).__copied);
|
||||
await expect.poll(copied).toBeTruthy();
|
||||
return (await copied()) as string;
|
||||
},
|
||||
async openInNewTab(id, gesture = 'modifier') {
|
||||
const [tab] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
savedViews
|
||||
.rowLink(id)
|
||||
.click(
|
||||
gesture === 'modifier'
|
||||
? { modifiers: ['ControlOrMeta'] }
|
||||
: { button: 'middle' },
|
||||
),
|
||||
]);
|
||||
return { page: tab, savedViews: savedViewsFor(tab) };
|
||||
},
|
||||
async openLink(url, view) {
|
||||
const tab = await page.context().newPage();
|
||||
const opened = savedViewsFor(tab);
|
||||
await Promise.all([
|
||||
opened.queryRan(view.filter, view.requestType),
|
||||
tab.goto(url.toString()),
|
||||
]);
|
||||
return { page: tab, savedViews: opened };
|
||||
},
|
||||
newTabCount: () => newTabs,
|
||||
async rename(id, displayName) {
|
||||
await savedViews.chooseRowAction(id, 'editDetails');
|
||||
await expect(savedViews.modalNameInput()).toBeVisible();
|
||||
await savedViews.modalNameInput().fill(displayName);
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'PUT' &&
|
||||
new URL(res.url()).pathname === viewPath(id),
|
||||
),
|
||||
savedViews.modalSubmit().click(),
|
||||
]);
|
||||
expect(response.ok(), `PUT ${SAVED_VIEWS_API}/${id}`).toBe(true);
|
||||
await expect(savedViews.modal()).toBeHidden();
|
||||
},
|
||||
async deleteView(id) {
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'DELETE' &&
|
||||
new URL(res.url()).pathname === viewPath(id),
|
||||
),
|
||||
press(savedViews.deleteConfirm()),
|
||||
]);
|
||||
await expect(savedViews.deleteConfirm()).toBeHidden();
|
||||
},
|
||||
|
||||
modal: () => tid('save-view-modal'),
|
||||
modalNameInput: () => tid('save-view-name'),
|
||||
modalSubmit: () => tid('save-view-submit'),
|
||||
deleteConfirm: () => tid('delete-saved-view-confirm'),
|
||||
async createFromHeader(displayName) {
|
||||
await press(savedViews.createButton());
|
||||
await savedViews.modalNameInput().fill(displayName);
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'POST' &&
|
||||
new URL(res.url()).pathname === `${BASE_PATH}${SAVED_VIEWS_API}`,
|
||||
),
|
||||
savedViews.modalSubmit().click(),
|
||||
]);
|
||||
expect(response.status(), `POST ${SAVED_VIEWS_API}`).toBe(201);
|
||||
const { id } = ((await response.json()) as { data: { id: string } }).data;
|
||||
trackSavedView(page, id);
|
||||
await expect(savedViews.modal()).toBeHidden();
|
||||
return id;
|
||||
},
|
||||
logsColumnHeaders: () => page.locator('thead th').filter({ hasText: /\S/ }),
|
||||
async openLogsFormatMenu() {
|
||||
await tid('periscope-btn-format-options').click();
|
||||
await expect(formatMenu()).toBeVisible();
|
||||
},
|
||||
async closeLogsFormatMenu() {
|
||||
await tid('periscope-btn-format-options').click();
|
||||
await expect(formatMenu()).toBeHidden();
|
||||
},
|
||||
selectedLogsFormat: () =>
|
||||
formatMenu()
|
||||
.locator('.menu-items .item')
|
||||
.filter({ has: page.locator('svg') }),
|
||||
logsMaxLines: () => formatMenu().getByTestId('max-lines-per-row'),
|
||||
logsFontSize: () => formatMenu().getByTestId('font-size-select'),
|
||||
explorerTab: (tab) => page.locator(`.${tab}-view-tab`),
|
||||
documentId: () => page.evaluate(() => performance.timeOrigin),
|
||||
queryBar: () =>
|
||||
page.locator('.query-where-clause-editor .cm-content').first(),
|
||||
async applyFilter(filter) {
|
||||
await Promise.all([
|
||||
savedViews.queryRan(filter),
|
||||
(async (): Promise<void> => {
|
||||
await savedViews.queryBar().click();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await page.keyboard.insertText(filter);
|
||||
await expect(savedViews.queryBar()).toHaveText(filter);
|
||||
await page.locator('[data-run-query-btn]').click();
|
||||
})(),
|
||||
]);
|
||||
},
|
||||
async update() {
|
||||
const id = savedViews.activeViewId();
|
||||
await savedViews.saveChangesButton().click();
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'PUT' &&
|
||||
new URL(res.url()).pathname === viewPath(id as string),
|
||||
),
|
||||
press(savedViews.saveAction('update')),
|
||||
]);
|
||||
expect(response.ok(), `PUT ${SAVED_VIEWS_API}/${id}`).toBe(true);
|
||||
await expect(savedViews.toast('View updated')).toBeVisible();
|
||||
},
|
||||
async discard() {
|
||||
await press(savedViews.discardButton());
|
||||
},
|
||||
|
||||
viewGets: (id) =>
|
||||
requests.filter(
|
||||
(request) =>
|
||||
request.method() === 'GET' &&
|
||||
new URL(request.url()).pathname === viewPath(id),
|
||||
),
|
||||
};
|
||||
|
||||
return savedViews;
|
||||
}
|
||||
|
||||
const withSavedViews = driver(
|
||||
'savedViews',
|
||||
({ page }) => savedViewsFor(page),
|
||||
(_, { page }) => removeTrackedSavedViews(page),
|
||||
);
|
||||
|
||||
export const test = withSavedViews(kit);
|
||||
|
||||
export { expect } from './kit';
|
||||
export * from './saved-views/spec';
|
||||
export type * from './saved-views/spec.types';
|
||||
export { queryRangeBody } from './saved-views/query';
|
||||
78
tests/e2e/drivers/saved-views/api.ts
generated
Normal file
78
tests/e2e/drivers/saved-views/api.ts
generated
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { authToken } from '../api';
|
||||
import { SAVED_VIEWS_API } from './spec';
|
||||
import type { GettableSavedView, PostableSavedView } from './spec.types';
|
||||
|
||||
async function bearer(page: Page): Promise<{ Authorization: string }> {
|
||||
return { Authorization: `Bearer ${await authToken(page)}` };
|
||||
}
|
||||
|
||||
export async function createViaApi(
|
||||
page: Page,
|
||||
view: PostableSavedView,
|
||||
): Promise<string> {
|
||||
const res = await page.request.post(SAVED_VIEWS_API, {
|
||||
data: view,
|
||||
headers: await bearer(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`POST ${SAVED_VIEWS_API} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: { id: string } }).data.id;
|
||||
}
|
||||
|
||||
export async function getViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<GettableSavedView> {
|
||||
const res = await page.request.get(`${SAVED_VIEWS_API}/${id}`, {
|
||||
headers: await bearer(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`GET ${SAVED_VIEWS_API}/${id} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: GettableSavedView }).data;
|
||||
}
|
||||
|
||||
/** The status alone, for asserting a view is gone. */
|
||||
export async function statusViaApi(page: Page, id: string): Promise<number> {
|
||||
const res = await page.request.get(`${SAVED_VIEWS_API}/${id}`, {
|
||||
headers: await bearer(page),
|
||||
});
|
||||
return res.status();
|
||||
}
|
||||
|
||||
/** Not asserted: a test that deleted its own view must not fail on cleanup. */
|
||||
export async function deleteViaApi(page: Page, id: string): Promise<void> {
|
||||
try {
|
||||
await page.request.delete(`${SAVED_VIEWS_API}/${id}`, {
|
||||
headers: await bearer(page),
|
||||
});
|
||||
} catch {
|
||||
/* the view outlives the run rather than hiding why it failed */
|
||||
}
|
||||
}
|
||||
|
||||
const trackedByPage = new WeakMap<Page, string[]>();
|
||||
|
||||
/** Registers a view for deletion when the test owning `page` ends. */
|
||||
export function trackSavedView(page: Page, id: string): void {
|
||||
trackedByPage.set(page, [...(trackedByPage.get(page) ?? []), id]);
|
||||
}
|
||||
|
||||
export async function removeTrackedSavedViews(page: Page): Promise<void> {
|
||||
const ids = trackedByPage.get(page) ?? [];
|
||||
trackedByPage.delete(page);
|
||||
for (const id of ids) {
|
||||
await deleteViaApi(page, id);
|
||||
}
|
||||
}
|
||||
40
tests/e2e/drivers/saved-views/query.ts
generated
Normal file
40
tests/e2e/drivers/saved-views/query.ts
generated
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import type { Request } from '@playwright/test';
|
||||
|
||||
import { isQueryRange } from '../query-range-mock';
|
||||
|
||||
interface QueryEnvelope {
|
||||
type: string;
|
||||
spec: {
|
||||
name?: string;
|
||||
signal?: string;
|
||||
filter?: { expression?: string };
|
||||
aggregations?: { expression?: string }[];
|
||||
groupBy?: { name: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface QueryRangeRequest {
|
||||
start: number;
|
||||
end: number;
|
||||
requestType: string;
|
||||
compositeQuery: { queries: QueryEnvelope[] };
|
||||
}
|
||||
|
||||
export function isQueryRangeRequest(request: Request): boolean {
|
||||
return request.method() === 'POST' && isQueryRange(request.url());
|
||||
}
|
||||
|
||||
export function queryRangeBody(request: Request): QueryRangeRequest {
|
||||
return request.postDataJSON() as QueryRangeRequest;
|
||||
}
|
||||
|
||||
/** The filter of every query in the request, in order. */
|
||||
export function filterExpressions(request: Request): string[] {
|
||||
return queryRangeBody(request).compositeQuery.queries.map(
|
||||
(query) => query.spec.filter?.expression ?? '',
|
||||
);
|
||||
}
|
||||
121
tests/e2e/drivers/saved-views/spec.ts
generated
Normal file
121
tests/e2e/drivers/saved-views/spec.ts
generated
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
// Typed builders for the saved view spec: pure data, no Playwright. The types
|
||||
// live in `spec.types.ts`.
|
||||
//
|
||||
// `/api/v2/saved_views`: create and update take the whole spec, and an update
|
||||
// replaces it, so a field left out is reset.
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import type {
|
||||
LogsViewOptions,
|
||||
PostableSavedView,
|
||||
SavedViewColumn,
|
||||
SavedViewPanelType,
|
||||
SavedViewRequestType,
|
||||
SeededView,
|
||||
} from './spec.types';
|
||||
|
||||
export const SAVED_VIEWS_API = '/api/v2/saved_views';
|
||||
|
||||
const REQUEST_TYPE: Record<SavedViewPanelType, SavedViewRequestType> = {
|
||||
list: 'raw',
|
||||
graph: 'time_series',
|
||||
table: 'scalar',
|
||||
};
|
||||
|
||||
/** Workers and browser projects share one org, so every view name is unique. */
|
||||
export function uniqueViewName(label: string): string {
|
||||
return `e2e sv ${label} ${randomBytes(4).toString('hex')}`;
|
||||
}
|
||||
|
||||
export const LOG_COLUMNS: Record<string, SavedViewColumn> = {
|
||||
timestamp: {
|
||||
name: 'timestamp',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: '',
|
||||
},
|
||||
body: {
|
||||
name: 'body',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: '',
|
||||
},
|
||||
serviceName: {
|
||||
name: 'service.name',
|
||||
signal: 'logs',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
severityText: {
|
||||
name: 'severity_text',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
};
|
||||
|
||||
export function logsView({
|
||||
displayName,
|
||||
filter = '',
|
||||
panelType = 'list',
|
||||
groupBy = [],
|
||||
columns = [LOG_COLUMNS.timestamp, LOG_COLUMNS.body],
|
||||
display,
|
||||
}: LogsViewOptions): PostableSavedView {
|
||||
const isList = panelType === 'list';
|
||||
return {
|
||||
generateName: true,
|
||||
source: 'logs',
|
||||
schemaVersion: 'v2',
|
||||
spec: {
|
||||
displayName,
|
||||
panelType,
|
||||
requestType: REQUEST_TYPE[panelType],
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
disabled: false,
|
||||
filter: { expression: filter },
|
||||
...(isList
|
||||
? {
|
||||
order: [
|
||||
{ key: { name: 'timestamp' }, direction: 'desc' },
|
||||
{ key: { name: 'id' }, direction: 'desc' },
|
||||
],
|
||||
}
|
||||
: {
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
groupBy: groupBy.map((name) => ({
|
||||
name,
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
})),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
selectedFields: isList ? columns : [],
|
||||
...(display && { display }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function seededView(id: string, view: PostableSavedView): SeededView {
|
||||
const [query] = view.spec.queries as {
|
||||
spec?: { filter?: { expression?: string } };
|
||||
}[];
|
||||
return {
|
||||
id,
|
||||
displayName: view.spec.displayName,
|
||||
filter: query?.spec?.filter?.expression ?? '',
|
||||
requestType: view.spec.requestType,
|
||||
};
|
||||
}
|
||||
69
tests/e2e/drivers/saved-views/spec.types.ts
generated
Normal file
69
tests/e2e/drivers/saved-views/spec.types.ts
generated
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
// The saved view spec of `/api/v2/saved_views`.
|
||||
|
||||
export type SavedViewSource = 'logs' | 'traces' | 'metrics' | 'meter';
|
||||
export type SavedViewPanelType = 'list' | 'graph' | 'table';
|
||||
|
||||
export type SavedViewRequestType = 'raw' | 'time_series' | 'scalar';
|
||||
|
||||
export interface SavedViewColumn {
|
||||
name: string;
|
||||
signal?: string;
|
||||
fieldContext?: string;
|
||||
fieldDataType?: string;
|
||||
}
|
||||
|
||||
export interface SavedViewDisplay {
|
||||
format?: string;
|
||||
maxLines?: number;
|
||||
fontSize?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface SavedViewSpec {
|
||||
displayName: string;
|
||||
panelType: SavedViewPanelType;
|
||||
requestType: SavedViewRequestType;
|
||||
queries: Record<string, unknown>[];
|
||||
selectedFields?: SavedViewColumn[];
|
||||
display?: SavedViewDisplay;
|
||||
}
|
||||
|
||||
export interface PostableSavedView {
|
||||
generateName: true;
|
||||
source: SavedViewSource;
|
||||
schemaVersion: 'v2';
|
||||
spec: SavedViewSpec;
|
||||
}
|
||||
|
||||
export interface GettableSavedView {
|
||||
id: string;
|
||||
name: string;
|
||||
source: SavedViewSource;
|
||||
createdBy: string;
|
||||
updatedBy?: string;
|
||||
spec: SavedViewSpec;
|
||||
}
|
||||
|
||||
export interface LogsViewOptions {
|
||||
displayName: string;
|
||||
filter?: string;
|
||||
panelType?: SavedViewPanelType;
|
||||
/** Group by attributes of a graph or table view, as resource attributes. */
|
||||
groupBy?: string[];
|
||||
/** Defaults to timestamp and body, which a list view always shows. */
|
||||
columns?: SavedViewColumn[];
|
||||
display?: SavedViewDisplay;
|
||||
}
|
||||
|
||||
export interface SeededView {
|
||||
id: string;
|
||||
displayName: string;
|
||||
/** The first query's filter, which the explorer sends once the view is open. */
|
||||
filter: string;
|
||||
/** What the explorer's own query for the view asks for: raw, time_series or scalar. */
|
||||
requestType: string;
|
||||
}
|
||||
154
tests/e2e/tests/query-builder/addons.spec.ts
Normal file
154
tests/e2e/tests/query-builder/addons.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
expect,
|
||||
EXPLORER,
|
||||
SUM_METRIC,
|
||||
test,
|
||||
} from '../../drivers/query-builder';
|
||||
|
||||
const HAVING = ['count() > 10', 'count() IN [1, 2]', 'count() NOT IN [1, 2]'];
|
||||
|
||||
test.describe('Query builder — add-ons', () => {
|
||||
for (const explorer of ['logs', 'traces'] as const) {
|
||||
test(`TC-21 ${explorer} group by keeps order through removal and reload`, async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER[explorer], { view: 'graph' });
|
||||
const addOns = qb.query('A').addOns;
|
||||
await addOns.open('group_by');
|
||||
|
||||
await addOns.groupBy.add('service.name');
|
||||
await addOns.groupBy.add('deployment.environment');
|
||||
await addOns.groupBy.add('k8s.namespace.name');
|
||||
await addOns.groupBy.remove('service.name');
|
||||
const fired = await qb.run();
|
||||
|
||||
const expected = ['deployment.environment', 'k8s.namespace.name'];
|
||||
expect(qb.spec(fired, 'A').groupBy?.map((key) => key.name)).toEqual(
|
||||
expected,
|
||||
);
|
||||
const reloaded = await qb.openExplorer(EXPLORER[explorer], {
|
||||
view: 'graph',
|
||||
query: qb.urlQuery(),
|
||||
});
|
||||
expect(qb.spec(reloaded, 'A').groupBy?.map((key) => key.name)).toEqual(
|
||||
expected,
|
||||
);
|
||||
expect(await qb.query('A').addOns.groupBy.values()).toEqual(expected);
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-21 metrics group by offers the metric’s own attributes', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.metrics);
|
||||
const query = qb.query('A');
|
||||
await query.metric.select(SUM_METRIC);
|
||||
|
||||
await query.addOns.groupBy.add('operation');
|
||||
const fired = await qb.run();
|
||||
|
||||
expect(qb.spec(fired, 'A').groupBy?.map((key) => key.name)).toEqual([
|
||||
'operation',
|
||||
]);
|
||||
});
|
||||
|
||||
for (const having of HAVING) {
|
||||
test(`TC-22 having "${having}" is sent verbatim and accepted`, async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
|
||||
const addOns = qb.query('A').addOns;
|
||||
await addOns.open('group_by');
|
||||
await addOns.groupBy.add('service.name');
|
||||
await addOns.open('having');
|
||||
|
||||
await addOns.having.type(having);
|
||||
await addOns.having.press('Escape');
|
||||
const fired = await qb.run();
|
||||
|
||||
expect(qb.spec(fired, 'A').having?.expression).toBe(having);
|
||||
expect(qb.console.failedResponses).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-23 the logs list orders by timestamp then id, traces by timestamp only', async ({
|
||||
qb,
|
||||
}) => {
|
||||
const logs = await qb.openExplorer(EXPLORER.logs, { view: 'list' });
|
||||
expect(qb.spec(logs, 'A').order?.map((o) => o.key.name)).toEqual([
|
||||
'timestamp',
|
||||
'id',
|
||||
]);
|
||||
|
||||
const traces = await qb.openExplorer(EXPLORER.traces, { view: 'list' });
|
||||
expect(qb.spec(traces, 'A').order?.map((o) => o.key.name)).toEqual([
|
||||
'timestamp',
|
||||
]);
|
||||
});
|
||||
|
||||
test('TC-24 a positive limit is sent; empty and zero send none', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
|
||||
const addOns = qb.query('A').addOns;
|
||||
await addOns.open('group_by');
|
||||
await addOns.groupBy.add('service.name');
|
||||
await addOns.open('limit');
|
||||
|
||||
await addOns.limit().fill('10');
|
||||
expect(qb.spec(await qb.run(), 'A').limit).toBe(10);
|
||||
|
||||
await addOns.limit().fill('');
|
||||
expect(qb.spec(await qb.run(), 'A')).not.toHaveProperty('limit');
|
||||
|
||||
await addOns.limit().fill('3');
|
||||
await qb.run();
|
||||
await addOns.limit().fill('0');
|
||||
expect(qb.spec(await qb.run(), 'A')).not.toHaveProperty('limit');
|
||||
});
|
||||
|
||||
test('TC-24 a limit input drops the sign of a negative number', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
|
||||
const addOns = qb.query('A').addOns;
|
||||
await addOns.open('group_by');
|
||||
await addOns.groupBy.add('service.name');
|
||||
await addOns.open('limit');
|
||||
await addOns.limit().fill('10');
|
||||
expect(qb.spec(await qb.run(), 'A').limit).toBe(10);
|
||||
|
||||
await addOns.limit().fill('-5');
|
||||
await expect(addOns.limit()).toHaveValue('5');
|
||||
expect(qb.spec(await qb.run(), 'A').limit).toBe(5);
|
||||
expect(qb.console.failedResponses).toEqual([]);
|
||||
});
|
||||
|
||||
test('TC-25 the legend prefills from group by and a custom one is kept', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
|
||||
const addOns = qb.query('A').addOns;
|
||||
await addOns.open('group_by');
|
||||
await addOns.groupBy.add('service.name');
|
||||
await addOns.groupBy.add('deployment.environment');
|
||||
|
||||
await addOns.open('legend_format');
|
||||
await expect(addOns.legend()).toHaveValue(
|
||||
'service.name = {{service.name}}, deployment.environment = {{deployment.environment}}',
|
||||
);
|
||||
|
||||
await addOns.legend().fill('{{service.name}} logs');
|
||||
await addOns.groupBy.add('k8s.namespace.name');
|
||||
await expect(addOns.legend()).toHaveValue('{{service.name}} logs');
|
||||
expect(qb.spec(await qb.run(), 'A').legend).toBe('{{service.name}} logs');
|
||||
});
|
||||
|
||||
test('TC-25 the legend opens empty without a group by', async ({ qb }) => {
|
||||
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
|
||||
|
||||
await qb.query('A').addOns.open('legend_format');
|
||||
|
||||
await expect(qb.query('A').addOns.legend()).toHaveValue('');
|
||||
});
|
||||
});
|
||||
80
tests/e2e/tests/query-builder/aggregation.spec.ts
Normal file
80
tests/e2e/tests/query-builder/aggregation.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { expect, EXPLORER, test } from '../../drivers/query-builder';
|
||||
|
||||
// One comma-separated term per entry in the request's `aggregations`.
|
||||
const EXPRESSIONS: Record<string, string[]> = {
|
||||
'a count': ['count()'],
|
||||
'a distinct count': ['count_distinct(service.name)'],
|
||||
'a percentile of a span field': ['p99(duration_nano)'],
|
||||
'two aggregations': ['count()', 'p99(duration_nano)'],
|
||||
};
|
||||
|
||||
test.describe('Query builder — logs and traces aggregations', () => {
|
||||
for (const [label, terms] of Object.entries(EXPRESSIONS)) {
|
||||
test(`TC-17 ${label} reaches the request`, async ({ qb }) => {
|
||||
await qb.openExplorer(EXPLORER.traces, { view: 'graph' });
|
||||
const aggregation = qb.query('A').aggregation;
|
||||
|
||||
await aggregation.type(terms.join(', '));
|
||||
await aggregation.press('Escape');
|
||||
const fired = await qb.run();
|
||||
|
||||
expect(qb.spec(fired, 'A').aggregations?.map((a) => a.expression)).toEqual(
|
||||
terms,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-18 the step interval reaches the request', async ({
|
||||
authedPage: page,
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.traces, { view: 'graph' });
|
||||
|
||||
await qb.query('A').step().fill('120');
|
||||
await page.keyboard.press('Tab');
|
||||
const fired = await qb.run();
|
||||
|
||||
expect(qb.spec(fired, 'A').stepInterval).toBe(120);
|
||||
});
|
||||
|
||||
test('TC-30 a trace operator replaces per-query aggregation until removed', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.traces, { view: 'graph' });
|
||||
await (await qb.addQuery('B')).filter.type("http.status_code = '500'");
|
||||
await qb.query('B').filter.press('Escape');
|
||||
|
||||
await qb.traceOperator.add();
|
||||
await qb.traceOperator.editor.type('A => B');
|
||||
await qb.traceOperator.editor.press('Escape');
|
||||
for (const name of ['A', 'B']) {
|
||||
await expect(qb.query(name).aggregation.root()).toHaveCount(0);
|
||||
await expect(qb.query(name).addOns.toggles()).toHaveCount(0);
|
||||
}
|
||||
const fired = await qb.run();
|
||||
const operator = fired
|
||||
.flatMap((body) => body.compositeQuery.queries)
|
||||
.find((query) => query.type === 'builder_trace_operator');
|
||||
expect(operator?.spec).toMatchObject({ expression: 'A => B' });
|
||||
|
||||
await qb.traceOperator.remove();
|
||||
await expect(qb.query('A').aggregation.root()).toBeVisible();
|
||||
await qb.rerunUntil(
|
||||
() =>
|
||||
qb
|
||||
.sent()
|
||||
.at(-1)
|
||||
?.compositeQuery.queries.some(
|
||||
(query) => query.type === 'builder_trace_operator',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-30 the logs explorer offers no trace matching', async ({ qb }) => {
|
||||
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
|
||||
|
||||
await expect(qb.query('A').root()).toBeVisible();
|
||||
await expect(qb.traceOperator.addButton()).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
76
tests/e2e/tests/query-builder/alerts.spec.ts
Normal file
76
tests/e2e/tests/query-builder/alerts.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { AlertType, expect, test as alertsTest } from '../../drivers/alerts';
|
||||
import { withQueryBuilder } from '../../drivers/query-builder';
|
||||
|
||||
const test = withQueryBuilder(alertsTest);
|
||||
|
||||
const FILTER_A = "http.method = 'GET'";
|
||||
const FILTER_B = "http.status_code = '500'";
|
||||
const TRACE_MATCH = 'A => B';
|
||||
|
||||
interface SavedRule {
|
||||
condition?: {
|
||||
compositeQuery?: {
|
||||
queries?: {
|
||||
type: string;
|
||||
spec: {
|
||||
name?: string;
|
||||
expression?: string;
|
||||
filter?: { expression?: string };
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('Query builder — alerts', () => {
|
||||
test('TC-06 a trace-matching traces alert saves and reopens intact', async ({
|
||||
authedPage: page,
|
||||
alerts,
|
||||
alertChannel,
|
||||
ownedRules,
|
||||
qb,
|
||||
}) => {
|
||||
await alerts.form.gotoV2({ alertType: AlertType.TRACES });
|
||||
await alerts.form.name().fill(`e2e-qb-tc06-${Date.now()}`);
|
||||
|
||||
await qb.query('A').filter.type(FILTER_A);
|
||||
await qb.query('A').filter.press('Escape');
|
||||
const b = await qb.addQuery('B');
|
||||
await b.filter.type(FILTER_B);
|
||||
await b.filter.press('Escape');
|
||||
await qb.traceOperator.add();
|
||||
await qb.traceOperator.editor.type(TRACE_MATCH);
|
||||
await qb.traceOperator.editor.press('Escape');
|
||||
// Blur first: the editor's blur re-renders the condition section, which
|
||||
// otherwise closes the channel select as its click opens it.
|
||||
await qb.traceOperator.editor.content().blur();
|
||||
await alerts.form.selectThresholdChannel(0, alertChannel.name);
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
|
||||
),
|
||||
alerts.form.saveButton().click(),
|
||||
]);
|
||||
await ownedRules.register(response);
|
||||
const saved = response.request().postDataJSON() as SavedRule;
|
||||
const queries = saved.condition?.compositeQuery?.queries ?? [];
|
||||
expect(queries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'builder_trace_operator',
|
||||
spec: expect.objectContaining({ expression: TRACE_MATCH }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const { data } = (await response.json()) as { data: { id: string } };
|
||||
await alerts.details.gotoOverview(String(data.id));
|
||||
|
||||
await expect.poll(() => qb.query('A').filter.expression()).toBe(FILTER_A);
|
||||
await expect.poll(() => qb.query('B').filter.expression()).toBe(FILTER_B);
|
||||
await expect
|
||||
.poll(() => qb.traceOperator.editor.expression())
|
||||
.toBe(TRACE_MATCH);
|
||||
});
|
||||
});
|
||||
72
tests/e2e/tests/query-builder/dashboards.spec.ts
Normal file
72
tests/e2e/tests/query-builder/dashboards.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { emptyDashboard, test as editorTest } from '../../drivers/panel-editor';
|
||||
import {
|
||||
expect,
|
||||
EXPLORER,
|
||||
withQueryBuilder,
|
||||
} from '../../drivers/query-builder';
|
||||
|
||||
const test = withQueryBuilder(editorTest);
|
||||
|
||||
const FILTER = "severity_text = 'ERROR'";
|
||||
const AGGREGATION = 'count_distinct(service.name)';
|
||||
|
||||
interface StoredQuery {
|
||||
spec: {
|
||||
filter?: { expression?: string };
|
||||
aggregations?: { expression?: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Where a non-List panel keeps its builder queries in the v2 dashboard spec. */
|
||||
interface StoredPanel {
|
||||
spec: {
|
||||
queries: {
|
||||
spec: { plugin: { kind: string; spec: { queries: StoredQuery[] } } };
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('Query builder — explorer to dashboard', () => {
|
||||
test('TC-05 a logs explorer query survives add to dashboard, save and reopen', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
qb,
|
||||
}) => {
|
||||
const dashboard = emptyDashboard();
|
||||
const title = dashboard.spec.display.name;
|
||||
const id = await dashboards.seed(dashboard);
|
||||
|
||||
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
|
||||
await qb.query('A').filter.type(FILTER);
|
||||
await qb.query('A').filter.press('Escape');
|
||||
await qb.query('A').aggregation.type(AGGREGATION);
|
||||
await qb.query('A').aggregation.press('Escape');
|
||||
await qb.run();
|
||||
|
||||
await qb.addToDashboard(title);
|
||||
await expect.poll(() => qb.query('A').filter.expression()).toBe(FILTER);
|
||||
await expect
|
||||
.poll(() => qb.query('A').aggregation.expression())
|
||||
.toBe(AGGREGATION);
|
||||
|
||||
await editor.save();
|
||||
// The shared spec types model only bare BuilderQuery plugins.
|
||||
const panels = (await dashboards.get(id)).spec.panels as unknown as Record<
|
||||
string,
|
||||
StoredPanel
|
||||
>;
|
||||
const [panelId] = Object.keys(panels);
|
||||
const plugin = panels[panelId].spec.queries[0].spec.plugin;
|
||||
expect(plugin.kind).toBe('signoz/CompositeQuery');
|
||||
expect(plugin.spec.queries[0].spec).toMatchObject({
|
||||
filter: { expression: FILTER },
|
||||
aggregations: [{ expression: AGGREGATION }],
|
||||
});
|
||||
|
||||
await dashboards.openEditor(id, panelId);
|
||||
await expect.poll(() => qb.query('A').filter.expression()).toBe(FILTER);
|
||||
await expect
|
||||
.poll(() => qb.query('A').aggregation.expression())
|
||||
.toBe(AGGREGATION);
|
||||
});
|
||||
});
|
||||
284
tests/e2e/tests/query-builder/filter.spec.ts
Normal file
284
tests/e2e/tests/query-builder/filter.spec.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { expect, EXPLORER, test } from '../../drivers/query-builder';
|
||||
|
||||
// Typing is real keystrokes: the regressions here (caret jumps, swallowed
|
||||
// Enter, the run shortcut) only show up through the keyboard path.
|
||||
|
||||
const RECENT_KEPT = "service.name = 'frontend' AND severity_text = 'WARN'";
|
||||
const RECENT_REMOVED = 'logger.name EXISTS';
|
||||
|
||||
const COMPOUND: Record<string, string> = {
|
||||
'NOT over a parenthesised OR':
|
||||
"NOT (severity_text = 'INFO' OR severity_text = 'WARN') AND service.name = 'frontend'",
|
||||
'nested groups':
|
||||
"(service.name = 'frontend' AND (severity_text = 'ERROR' OR severity_text = 'WARN'))",
|
||||
'NOT IN with EXISTS':
|
||||
"service.name NOT IN ['frontend', 'adservice'] AND logger.name EXISTS",
|
||||
};
|
||||
|
||||
const queryWithFilter = (expression: string): unknown => ({
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryFormulas: [],
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
dataSource: 'logs',
|
||||
expression: 'A',
|
||||
filter: { expression },
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const VERBATIM: Record<string, string> = {
|
||||
'an escaped quote': "logger.name = 'it\\'s'",
|
||||
'a double-quoted string': 'logger.name = "double quoted"',
|
||||
'a quoted numeric string': "logger.name = '123'",
|
||||
'a bare number': 'logger.name = 123',
|
||||
'a boolean': 'logger.name = true',
|
||||
'spaces and a backslash': "logger.name = 'a b\\\\c'",
|
||||
};
|
||||
|
||||
test.describe('Query builder — filter editor', () => {
|
||||
test('TC-07 key, operator and value suggestions build the expression', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const filter = qb.query('A').filter;
|
||||
|
||||
await filter.type('severity_te');
|
||||
await filter.openSuggestions();
|
||||
await filter.suggestion('severity_text').first().click();
|
||||
await expect.poll(() => filter.expression()).toMatch(/^severity_text\s*$/);
|
||||
|
||||
await filter.openSuggestions();
|
||||
await expect(filter.suggestion(/^=/).first()).toBeVisible();
|
||||
await filter.append('= ');
|
||||
await filter.openSuggestions();
|
||||
await filter.suggestion('ERROR').first().click();
|
||||
await expect
|
||||
.poll(() => filter.expression())
|
||||
.toMatch(/^severity_text = 'ERROR'\s*$/);
|
||||
|
||||
const fired = await qb.run();
|
||||
expect(qb.spec(fired, 'A').filter?.expression?.trim()).toBe(
|
||||
"severity_text = 'ERROR'",
|
||||
);
|
||||
});
|
||||
|
||||
for (const [label, expression] of Object.entries(VERBATIM)) {
|
||||
test(`TC-08 ${label} is sent and restored verbatim`, async ({
|
||||
authedPage: page,
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const filter = qb.query('A').filter;
|
||||
|
||||
await filter.type(expression);
|
||||
await filter.press('Escape');
|
||||
const fired = await qb.run();
|
||||
expect(qb.spec(fired, 'A').filter?.expression).toBe(expression);
|
||||
|
||||
await page.reload();
|
||||
await expect(filter.content()).toBeVisible();
|
||||
await expect.poll(() => filter.expression()).toBe(expression);
|
||||
await expect(qb.query('A').filterStatus()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-11 the status icon shows only for an unfocused invalid filter', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const query = qb.query('A');
|
||||
|
||||
await query.filter.type('service.name =');
|
||||
await expect(query.filterStatus()).toHaveCount(0);
|
||||
|
||||
await query.filter.content().blur();
|
||||
await expect(query.filterStatus()).toBeVisible();
|
||||
|
||||
await query.filter.type("service.name = 'frontend'");
|
||||
await query.filter.content().blur();
|
||||
await expect(query.filterStatus()).toHaveCount(0);
|
||||
|
||||
await query.filter.clear();
|
||||
await query.filter.content().blur();
|
||||
await expect(query.filterStatus()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-12 Enter and Shift+Enter never insert a newline or run', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const filter = qb.query('A').filter;
|
||||
await filter.type("severity_text = 'ERROR'");
|
||||
await filter.press('Escape');
|
||||
await expect(filter.suggestions()).toHaveCount(0);
|
||||
const sentBefore = qb.sent().length;
|
||||
|
||||
await filter.press('Enter');
|
||||
await filter.press('Shift+Enter');
|
||||
|
||||
await expect(filter.lineCount()).toHaveCount(1);
|
||||
await expect.poll(() => filter.expression()).toBe("severity_text = 'ERROR'");
|
||||
expect(qb.sent()).toHaveLength(sentBefore);
|
||||
});
|
||||
|
||||
for (const explorer of ['logs', 'traces'] as const) {
|
||||
test(`TC-13 Cmd/Ctrl+Enter in the ${explorer} filter runs the edited query`, async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER[explorer]);
|
||||
const filter = qb.query('A').filter;
|
||||
await filter.type("service.name = 'frontend'");
|
||||
|
||||
const fired = await qb.runShortcut();
|
||||
|
||||
expect(qb.spec(fired, 'A').filter?.expression).toBe(
|
||||
"service.name = 'frontend'",
|
||||
);
|
||||
await expect(filter.lineCount()).toHaveCount(1);
|
||||
expect(qb.urlQuery()).toMatchObject({ queryType: 'builder' });
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-14 Escape closes suggestions and keeps the text', async ({ qb }) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const filter = qb.query('A').filter;
|
||||
|
||||
await filter.type('serv');
|
||||
await filter.openSuggestions();
|
||||
await filter.press('Escape');
|
||||
|
||||
await expect(filter.suggestions()).toHaveCount(0);
|
||||
await expect.poll(() => filter.expression()).toBe('serv');
|
||||
await filter.press('i');
|
||||
await expect.poll(() => filter.expression()).toBe('servi');
|
||||
|
||||
await filter.press('Escape');
|
||||
await filter.press('Escape');
|
||||
await expect.poll(() => filter.expression()).toBe('servi');
|
||||
await expect(filter.content()).toBeFocused();
|
||||
});
|
||||
|
||||
test('TC-09 value suggestions fill an IN list', async ({ qb }) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const filter = qb.query('A').filter;
|
||||
|
||||
await filter.type('service.name IN ');
|
||||
await filter.openSuggestions();
|
||||
await filter.suggestion('frontend').first().click();
|
||||
await expect
|
||||
.poll(() => filter.expression())
|
||||
.toBe("service.name IN ['frontend'] ");
|
||||
|
||||
await filter.type("service.name IN ['frontend', 'cartservice']");
|
||||
await filter.press('Escape');
|
||||
const fired = await qb.run();
|
||||
|
||||
expect(qb.spec(fired, 'A').filter?.expression).toBe(
|
||||
"service.name IN ['frontend', 'cartservice']",
|
||||
);
|
||||
await expect(qb.query('A').filterStatus()).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const [label, expression] of Object.entries(COMPOUND)) {
|
||||
test(`TC-10 ${label} is valid and sent verbatim`, async ({ qb }) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const query = qb.query('A');
|
||||
|
||||
await query.filter.type(expression);
|
||||
await query.filter.press('Escape');
|
||||
await query.filter.content().blur();
|
||||
await expect(query.filterStatus()).toHaveCount(0);
|
||||
const fired = await qb.run();
|
||||
|
||||
expect(qb.spec(fired, 'A').filter?.expression).toBe(expression);
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-15 an expression with CRLF from the URL loads without a RangeError', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs, {
|
||||
query: queryWithFilter("body CONTAINS 'line1\r\nline2'"),
|
||||
});
|
||||
|
||||
await expect.poll(() => qb.query('A').filter.expression()).toContain('line1');
|
||||
expect(qb.console.errors.filter((e) => e.includes('RangeError'))).toEqual([]);
|
||||
await qb.query('A').filter.append(" AND severity_text = 'ERROR'");
|
||||
await expect
|
||||
.poll(() => qb.query('A').filter.expression())
|
||||
.toMatch(/severity_text = 'ERROR'$/);
|
||||
expect(qb.console.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('TC-15 recent searches are offered, applied and deleted', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const filter = qb.query('A').filter;
|
||||
for (const expression of [RECENT_KEPT, RECENT_REMOVED]) {
|
||||
await filter.type(expression);
|
||||
await filter.press('Escape');
|
||||
await qb.run();
|
||||
}
|
||||
|
||||
await filter.clear();
|
||||
await filter.openSuggestions();
|
||||
await expect(filter.suggestion(RECENT_KEPT)).toBeVisible();
|
||||
await expect(filter.suggestion(RECENT_REMOVED)).toBeVisible();
|
||||
|
||||
await filter.suggestion(RECENT_KEPT).click();
|
||||
await expect.poll(() => filter.expression()).toBe(RECENT_KEPT);
|
||||
|
||||
await filter.clear();
|
||||
await filter.openSuggestions();
|
||||
await filter
|
||||
.suggestion(RECENT_REMOVED)
|
||||
.getByRole('button', { name: 'Remove from recent searches' })
|
||||
.click();
|
||||
await expect(filter.suggestion(RECENT_REMOVED)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-15 recent searches survive a reload and stay with their signal', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
await qb.query('A').filter.type(RECENT_KEPT);
|
||||
await qb.query('A').filter.press('Escape');
|
||||
await qb.run();
|
||||
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
await qb.query('A').filter.clear();
|
||||
await qb.query('A').filter.openSuggestions();
|
||||
await expect(qb.query('A').filter.suggestion(RECENT_KEPT)).toBeVisible();
|
||||
|
||||
await qb.openExplorer(EXPLORER.traces);
|
||||
await qb.query('A').filter.clear();
|
||||
await qb.query('A').filter.openSuggestions();
|
||||
await expect(qb.query('A').filter.suggestion(RECENT_KEPT)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-16 a quick-filter value toggles between excluded and cleared', async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const filter = qb.query('A').filter;
|
||||
const handTyped = "service.name = 'frontend'";
|
||||
const excluded = `${handTyped} severity_text not in ['ERROR']`;
|
||||
const error = qb.quickFilter('Severity Text', 'ERROR');
|
||||
await filter.type(handTyped);
|
||||
await qb.run();
|
||||
|
||||
for (const expected of [excluded, handTyped, excluded]) {
|
||||
const fired = await error.toggle();
|
||||
|
||||
expect(qb.spec(fired, 'A').filter?.expression).toBe(expected);
|
||||
await expect.poll(() => filter.expression()).toBe(expected);
|
||||
}
|
||||
await expect(error.checkbox()).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
});
|
||||
106
tests/e2e/tests/query-builder/metrics.spec.ts
Normal file
106
tests/e2e/tests/query-builder/metrics.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
expect,
|
||||
EXPLORER,
|
||||
type MetricTypes,
|
||||
OTHER_SUM_METRIC,
|
||||
SUM_METRIC,
|
||||
test as base,
|
||||
withMetricTypes,
|
||||
} from '../../drivers/query-builder';
|
||||
|
||||
const test = withMetricTypes(base);
|
||||
|
||||
const MOMENT_AGGREGATIONS = ['Sum', 'Avg', 'Min', 'Max'];
|
||||
|
||||
interface TypeDefaults {
|
||||
metric: (types: MetricTypes) => string;
|
||||
time?: string;
|
||||
space: string;
|
||||
spaceOptions: string[];
|
||||
}
|
||||
|
||||
const DEFAULTS: Record<string, TypeDefaults> = {
|
||||
sum: {
|
||||
metric: () => SUM_METRIC,
|
||||
time: 'Rate',
|
||||
space: 'Sum',
|
||||
spaceOptions: MOMENT_AGGREGATIONS,
|
||||
},
|
||||
gauge: {
|
||||
metric: (types) => types.gauge,
|
||||
time: 'Avg',
|
||||
space: 'Avg',
|
||||
spaceOptions: MOMENT_AGGREGATIONS,
|
||||
},
|
||||
histogram: {
|
||||
metric: (types) => types.histogram,
|
||||
space: 'P90',
|
||||
spaceOptions: ['P50', 'P75', 'P90', 'P95', 'P99'],
|
||||
},
|
||||
};
|
||||
|
||||
test.describe('Query builder — metric aggregations', () => {
|
||||
for (const [type, expected] of Object.entries(DEFAULTS)) {
|
||||
test(`TC-19 a ${type} metric gets its type's default aggregations`, async ({
|
||||
metricTypes,
|
||||
qb,
|
||||
}) => {
|
||||
const metricName = expected.metric(metricTypes);
|
||||
await qb.openExplorer(EXPLORER.metrics);
|
||||
const metric = qb.query('A').metric;
|
||||
|
||||
await metric.select(metricName);
|
||||
|
||||
await expect(metric.spaceAggregation()).toHaveText(expected.space);
|
||||
expect(await metric.spaceOptions()).toEqual(expected.spaceOptions);
|
||||
const fired = await qb.run();
|
||||
expect(qb.spec(fired, 'A').aggregations?.[0]).toMatchObject({
|
||||
metricName,
|
||||
spaceAggregation: expected.space.toLowerCase(),
|
||||
...(expected.time && { timeAggregation: expected.time.toLowerCase() }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-19 a histogram has no time aggregation control', async ({
|
||||
metricTypes,
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.metrics);
|
||||
|
||||
await qb.query('A').metric.select(metricTypes.histogram);
|
||||
|
||||
await expect(qb.query('A').metric.spaceAggregation()).toHaveText('P90');
|
||||
await expect(qb.query('A').metric.timeAggregation()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-20 aggregations reset only when the metric type changes', async ({
|
||||
metricTypes,
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.metrics);
|
||||
const metric = qb.query('A').metric;
|
||||
|
||||
await metric.select(SUM_METRIC);
|
||||
await metric.setTimeAggregation('Increase');
|
||||
await metric.setSpaceAggregation('Max');
|
||||
|
||||
await metric.select(OTHER_SUM_METRIC);
|
||||
await expect(metric.timeAggregation()).toHaveText('Increase');
|
||||
await expect(metric.spaceAggregation()).toHaveText('Max');
|
||||
|
||||
await metric.select(metricTypes.gauge);
|
||||
await expect(metric.timeAggregation()).toHaveText('Avg');
|
||||
await expect(metric.spaceAggregation()).toHaveText('Avg');
|
||||
|
||||
await metric.select(SUM_METRIC);
|
||||
await expect(metric.timeAggregation()).toHaveText('Rate');
|
||||
await expect(metric.spaceAggregation()).toHaveText('Sum');
|
||||
const fired = await qb.run();
|
||||
expect(qb.spec(fired, 'A').aggregations?.[0]).toMatchObject({
|
||||
metricName: SUM_METRIC,
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
});
|
||||
});
|
||||
});
|
||||
136
tests/e2e/tests/query-builder/multi-query.spec.ts
Normal file
136
tests/e2e/tests/query-builder/multi-query.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
logsCountQuery,
|
||||
metricsQuery,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test as editorTest,
|
||||
} from '../../drivers/panel-editor';
|
||||
import {
|
||||
expect,
|
||||
OTHER_SUM_METRIC,
|
||||
SUM_METRIC,
|
||||
withQueryBuilder,
|
||||
} from '../../drivers/query-builder';
|
||||
|
||||
// An added query continues the previous query's signal. On these metrics
|
||||
// panels it starts with no metric, and Run does nothing until one is picked.
|
||||
const test = withQueryBuilder(editorTest);
|
||||
|
||||
const SIGNAL_LABEL = { logs: 'Logs', traces: 'Traces' } as const;
|
||||
|
||||
const CLONED_FILTER = "operation = '/ads/get'";
|
||||
|
||||
test.describe('Query builder — multiple queries', () => {
|
||||
for (const signal of ['logs', 'traces'] as const) {
|
||||
test(`TC-28 a query added to a ${signal} panel stays ${signal}`, async ({
|
||||
dashboards,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: logsCountQuery({ signal }) }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
const added = await qb.addQuery('B');
|
||||
|
||||
await expect(added.signal()).toHaveText(SIGNAL_LABEL[signal]);
|
||||
await expect(added.aggregation.root()).toBeVisible();
|
||||
await expect(added.metric.input()).toHaveCount(0);
|
||||
await added.filter.type("service.name = 'frontend'");
|
||||
await added.filter.press('Escape');
|
||||
await qb.rerunUntil(() => qb.latest('B')?.signal, signal);
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-28 a deleted name is reused by the next added query', async ({
|
||||
dashboards,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
for (const name of ['B', 'C']) {
|
||||
await (await qb.addQuery(name)).metric.select(OTHER_SUM_METRIC);
|
||||
}
|
||||
expect(await qb.queryNames()).toEqual(['A', 'B', 'C']);
|
||||
|
||||
await qb.query('B').delete();
|
||||
expect(await qb.queryNames()).toEqual(['A', 'C']);
|
||||
const reAdded = await qb.addQuery('B');
|
||||
expect(await qb.queryNames()).toEqual(['A', 'C', 'B']);
|
||||
await reAdded.metric.select(SUM_METRIC);
|
||||
await expect(reAdded.metric.input()).toHaveValue(SUM_METRIC);
|
||||
await expect(qb.query('C').metric.input()).toHaveValue(OTHER_SUM_METRIC);
|
||||
});
|
||||
|
||||
test('TC-28 a clone and a hidden query reach the request', async ({
|
||||
dashboards,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery({ filter: CLONED_FILTER }) }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await (await qb.addQuery('B')).metric.select(OTHER_SUM_METRIC);
|
||||
|
||||
await qb.query('A').clone();
|
||||
await expect
|
||||
.poll(() => qb.query('C').filter.expression())
|
||||
.toBe(CLONED_FILTER);
|
||||
await qb.query('B').toggleVisibility();
|
||||
await qb.runButton().click();
|
||||
|
||||
await expect.poll(() => qb.latest('B')?.disabled).toBe(true);
|
||||
expect(qb.latest('C')).toMatchObject({
|
||||
filter: { expression: CLONED_FILTER },
|
||||
aggregations: [expect.objectContaining({ metricName: SUM_METRIC })],
|
||||
});
|
||||
});
|
||||
|
||||
test('TC-28 the last remaining query offers no delete', async ({
|
||||
dashboards,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await (await qb.addQuery('B')).metric.select(OTHER_SUM_METRIC);
|
||||
|
||||
await qb.query('B').delete();
|
||||
await qb.query('A').actions().click();
|
||||
|
||||
await expect(qb.query('A').menuItem('clone')).toBeVisible();
|
||||
await expect(qb.query('A').menuItem('delete')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-29 a formula over two queries reaches the request', async ({
|
||||
dashboards,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await (await qb.addQuery('B')).metric.select(OTHER_SUM_METRIC);
|
||||
|
||||
const formula = await qb.addFormula('F1');
|
||||
await formula.expression().fill('A / B');
|
||||
await formula.legend().fill('ratio');
|
||||
await qb.runButton().click();
|
||||
await expect
|
||||
.poll(() => qb.latest('F1'))
|
||||
.toMatchObject({ expression: 'A / B', legend: 'ratio' });
|
||||
|
||||
await formula.delete();
|
||||
await qb.rerunUntil(
|
||||
() =>
|
||||
qb
|
||||
.sent()
|
||||
.at(-1)
|
||||
?.compositeQuery.queries.map((q) => q.spec.name),
|
||||
['A', 'B'],
|
||||
);
|
||||
});
|
||||
});
|
||||
203
tests/e2e/tests/query-builder/panel-editor.spec.ts
Normal file
203
tests/e2e/tests/query-builder/panel-editor.spec.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
logsCountQuery,
|
||||
metricsQuery,
|
||||
PanelKind,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test as editorTest,
|
||||
} from '../../drivers/panel-editor';
|
||||
import {
|
||||
expect,
|
||||
reduceToLabel,
|
||||
SUM_METRIC,
|
||||
withMetricTypes,
|
||||
withQueryBuilder,
|
||||
} from '../../drivers/query-builder';
|
||||
|
||||
const test = withMetricTypes(withQueryBuilder(editorTest));
|
||||
|
||||
const LOGS_FILTER = "severity_text = 'ERROR'";
|
||||
const TRACES_FILTER = "http.method = 'GET'";
|
||||
const PROMQL = 'sum(rate(signoz_calls_total[5m]))';
|
||||
|
||||
const QUERIES = {
|
||||
logs: () => logsCountQuery(),
|
||||
traces: () => logsCountQuery({ signal: 'traces' }),
|
||||
metrics: () => metricsQuery(),
|
||||
};
|
||||
|
||||
const ALL = ['group_by', 'having', 'order_by', 'limit', 'legend_format'];
|
||||
const NO_HAVING = ['group_by', 'order_by', 'limit', 'legend_format'];
|
||||
const METRICS = ['having', 'order_by', 'limit', 'legend_format'];
|
||||
|
||||
/**
|
||||
* Add-on toggles each panel kind offers per signal. Metrics group by lives in
|
||||
* the aggregation row, so it is never a toggle there.
|
||||
*/
|
||||
const ADD_ONS: Record<
|
||||
keyof typeof QUERIES,
|
||||
Partial<Record<string, string[]>>
|
||||
> = {
|
||||
logs: {
|
||||
[PanelKind.TimeSeries]: ALL,
|
||||
[PanelKind.BarChart]: ALL,
|
||||
[PanelKind.Table]: ALL,
|
||||
[PanelKind.PieChart]: ALL,
|
||||
[PanelKind.Number]: ['legend_format'],
|
||||
[PanelKind.Heatmap]: NO_HAVING,
|
||||
},
|
||||
traces: {
|
||||
[PanelKind.TimeSeries]: ALL,
|
||||
[PanelKind.BarChart]: ALL,
|
||||
[PanelKind.Table]: ALL,
|
||||
[PanelKind.PieChart]: ALL,
|
||||
[PanelKind.Number]: ['legend_format'],
|
||||
[PanelKind.Heatmap]: NO_HAVING,
|
||||
},
|
||||
metrics: {
|
||||
[PanelKind.TimeSeries]: METRICS,
|
||||
[PanelKind.BarChart]: METRICS,
|
||||
[PanelKind.Table]: [...METRICS, 'reduce_to'],
|
||||
[PanelKind.PieChart]: [...METRICS, 'reduce_to'],
|
||||
[PanelKind.Number]: ['legend_format', 'reduce_to'],
|
||||
[PanelKind.Heatmap]: ['order_by', 'limit', 'legend_format', 'bucket_options'],
|
||||
},
|
||||
};
|
||||
|
||||
test.describe('Query builder — panel editor', () => {
|
||||
for (const [signal, kinds] of Object.entries(ADD_ONS)) {
|
||||
for (const [kind, expected] of Object.entries(kinds)) {
|
||||
test(`TC-27 ${signal} on ${kind.replace('signoz/', '')} offers its add-ons`, async ({
|
||||
dashboards,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({
|
||||
kind: kind as PanelKind,
|
||||
query: QUERIES[signal as keyof typeof QUERIES](),
|
||||
}),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await expect(qb.query('A').addOns.toggles()).toHaveCount(
|
||||
expected?.length ?? 0,
|
||||
);
|
||||
const toggles = await qb
|
||||
.query('A')
|
||||
.addOns.toggles()
|
||||
.evaluateAll((nodes) =>
|
||||
nodes.map((node) =>
|
||||
(node.getAttribute('data-testid') ?? '').replace('query-add-on-', ''),
|
||||
),
|
||||
);
|
||||
expect(toggles).toEqual(expected);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('TC-27 a limit hidden by a kind switch reopens with its value', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: logsCountQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
const addOns = qb.query('A').addOns;
|
||||
await addOns.open('limit');
|
||||
await addOns.limit().fill('7');
|
||||
|
||||
await editor.switchType(PanelKind.Number);
|
||||
await expect(addOns.content('limit')).toHaveCount(0);
|
||||
await editor.switchType(PanelKind.TimeSeries);
|
||||
|
||||
await expect(addOns.limit()).toHaveValue('7');
|
||||
// The kind switch already fetched: Run can be a cache hit.
|
||||
await qb.runButton().click();
|
||||
await expect.poll(() => qb.latest('A')?.limit).toBe(7);
|
||||
});
|
||||
|
||||
test('TC-26 reduce-to follows the metric type and keeps a choice within it', async ({
|
||||
dashboards,
|
||||
metricTypes,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ kind: PanelKind.Table, query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
const query = qb.query('A');
|
||||
await expect(query.addOns.reduceTo.value()).toHaveText(reduceToLabel('Sum'));
|
||||
|
||||
await query.addOns.reduceTo.set('Max');
|
||||
await query.metric.select('signoz_latency_count');
|
||||
await expect(query.addOns.reduceTo.value()).toHaveText(reduceToLabel('Max'));
|
||||
|
||||
await query.metric.select(metricTypes.gauge);
|
||||
await expect(query.addOns.reduceTo.value()).toHaveText(
|
||||
reduceToLabel('Average'),
|
||||
);
|
||||
|
||||
await query.metric.select(SUM_METRIC);
|
||||
await expect(query.addOns.reduceTo.value()).toHaveText(reduceToLabel('Sum'));
|
||||
expect(qb.spec(await qb.run(), 'A').aggregations?.[0]).toMatchObject({
|
||||
metricName: SUM_METRIC,
|
||||
reduceTo: 'sum',
|
||||
});
|
||||
});
|
||||
|
||||
test('TC-31 switching signal restores the query left on each signal', async ({
|
||||
dashboards,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: logsCountQuery({ groupBy: [] }) }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
const query = qb.query('A');
|
||||
await query.filter.type(LOGS_FILTER);
|
||||
await query.filter.press('Escape');
|
||||
|
||||
await query.setSignal('Traces');
|
||||
await expect.poll(() => query.filter.expression()).toBe('');
|
||||
await query.filter.type(TRACES_FILTER);
|
||||
await query.filter.press('Escape');
|
||||
|
||||
await query.setSignal('Logs');
|
||||
await expect.poll(() => query.filter.expression()).toBe(LOGS_FILTER);
|
||||
await query.setSignal('Traces');
|
||||
await expect.poll(() => query.filter.expression()).toBe(TRACES_FILTER);
|
||||
await qb.runButton().click();
|
||||
await expect
|
||||
.poll(() => qb.latest('A'))
|
||||
.toMatchObject({
|
||||
signal: 'traces',
|
||||
filter: { expression: TRACES_FILTER },
|
||||
});
|
||||
});
|
||||
|
||||
test('TC-33 a PromQL query is sent as promql and kept across tab switches', async ({
|
||||
dashboards,
|
||||
qb,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await qb.tab('promql').click();
|
||||
await qb.promql().fill(PROMQL);
|
||||
const fired = await qb.run();
|
||||
expect(fired.at(-1)?.compositeQuery.queries[0]).toMatchObject({
|
||||
type: 'promql',
|
||||
spec: { name: 'A', query: PROMQL },
|
||||
});
|
||||
|
||||
await qb.tab('clickhouse_sql').click();
|
||||
await qb.tab('builder').click();
|
||||
await expect(qb.query('A').metric.spaceAggregation()).toHaveText('Sum');
|
||||
await qb.tab('promql').click();
|
||||
await expect(qb.promql()).toHaveValue(PROMQL);
|
||||
});
|
||||
});
|
||||
80
tests/e2e/tests/query-builder/run.spec.ts
Normal file
80
tests/e2e/tests/query-builder/run.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { expect, EXPLORER, test } from '../../drivers/query-builder';
|
||||
import {
|
||||
holdQueryRange,
|
||||
mockQueryRange,
|
||||
mockQueryRangeError,
|
||||
QueryRange,
|
||||
unmockQueryRange,
|
||||
} from '../../drivers/query-range-mock';
|
||||
|
||||
test.describe('Query builder — run and cancel', () => {
|
||||
test('TC-34 a held run can be cancelled and the next run goes through', async ({
|
||||
authedPage: page,
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
const held = await holdQueryRange(page, QueryRange.empty());
|
||||
await qb.query('A').filter.type("severity_text = 'WARN'");
|
||||
await qb.query('A').filter.press('Escape');
|
||||
|
||||
await qb.runButton().click();
|
||||
await expect(qb.runButton()).toHaveText('Cancel');
|
||||
await qb.runButton().click();
|
||||
await expect(qb.runButton()).toHaveText(/Run Query/);
|
||||
await expect(qb.logsResults()).toContainText('Query cancelled');
|
||||
|
||||
held.release();
|
||||
await unmockQueryRange(page);
|
||||
await qb.query('A').filter.type("severity_text = 'ERROR'");
|
||||
await qb.query('A').filter.press('Escape');
|
||||
// The cancelled query is re-sent alongside; the newest request wins.
|
||||
await qb.run();
|
||||
expect(qb.latest('A')?.filter?.expression).toBe("severity_text = 'ERROR'");
|
||||
await expect(qb.logsResults()).not.toContainText('Query cancelled');
|
||||
});
|
||||
|
||||
test('TC-35 a failed run keeps the query editable and the next run recovers', async ({
|
||||
authedPage: page,
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
await mockQueryRangeError(page, 'mocked query failure');
|
||||
await qb.query('A').filter.type("severity_text = 'INFO'");
|
||||
await qb.query('A').filter.press('Escape');
|
||||
|
||||
await qb.runButton().click();
|
||||
// The failed request is retried before the error settles.
|
||||
await expect(qb.runButton()).toHaveText(/Run Query/, { timeout: 20_000 });
|
||||
await expect(qb.logsResults()).toContainText('mocked query failure');
|
||||
await expect
|
||||
.poll(() => qb.query('A').filter.expression())
|
||||
.toBe("severity_text = 'INFO'");
|
||||
|
||||
await unmockQueryRange(page);
|
||||
await qb.query('A').filter.type("severity_text = 'WARN'");
|
||||
await qb.query('A').filter.press('Escape');
|
||||
const fired = await qb.run();
|
||||
expect(qb.spec(fired, 'A').filter?.expression).toBe("severity_text = 'WARN'");
|
||||
await expect(qb.logsResults()).not.toContainText('mocked query failure');
|
||||
});
|
||||
|
||||
test('TC-35 an empty result leaves the query unchanged', async ({
|
||||
authedPage: page,
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs);
|
||||
await mockQueryRange(page, QueryRange.empty());
|
||||
await qb.query('A').filter.type("service.name = 'nothing-here'");
|
||||
await qb.query('A').filter.press('Escape');
|
||||
|
||||
const fired = await qb.run();
|
||||
|
||||
expect(qb.spec(fired, 'A').filter?.expression).toBe(
|
||||
"service.name = 'nothing-here'",
|
||||
);
|
||||
await expect
|
||||
.poll(() => qb.query('A').filter.expression())
|
||||
.toBe("service.name = 'nothing-here'");
|
||||
expect(qb.console.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
194
tests/e2e/tests/query-builder/url.spec.ts
Normal file
194
tests/e2e/tests/query-builder/url.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { expect, EXPLORER, test } from '../../drivers/query-builder';
|
||||
|
||||
// A shared or hand-edited explorer link must never take the page down: the
|
||||
// param is parsed, repaired and rewritten before the builder mounts.
|
||||
|
||||
const MALFORMED: Record<string, string> = {
|
||||
'not JSON': 'not-json',
|
||||
'truncated JSON': '%7B',
|
||||
'a JSON string': encodeURIComponent('"a string"'),
|
||||
'a JSON array': encodeURIComponent('[]'),
|
||||
'JSON null': 'null',
|
||||
'an empty JSON string': '%22%22',
|
||||
'double encoded': encodeURIComponent(
|
||||
encodeURIComponent(JSON.stringify({ queryType: 'builder' })),
|
||||
),
|
||||
};
|
||||
|
||||
const PARTIAL: Record<string, unknown> = {
|
||||
'an empty object': {},
|
||||
'builder with no builder block': { queryType: 'builder' },
|
||||
'builder with no queryData': { queryType: 'builder', builder: {} },
|
||||
'empty queryData': {
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [], queryFormulas: [] },
|
||||
},
|
||||
'a query with no dataSource': {
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ queryName: 'A' }] },
|
||||
},
|
||||
'an unknown queryType': { queryType: 'zzz' },
|
||||
'non-array promql and clickhouse blocks': {
|
||||
queryType: 'builder',
|
||||
promql: {},
|
||||
clickhouse_sql: 'x',
|
||||
builder: { queryData: [{ queryName: 'A', dataSource: 'logs' }] },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Saved before the v5 builder: structured filters, a having array and an
|
||||
* aggregate operator. The old having filter wrote `columnName` upper-cased
|
||||
* (`COUNT()`).
|
||||
*/
|
||||
const legacyLogsQuery = (having: unknown[]): unknown => ({
|
||||
queryType: 'builder',
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
builder: {
|
||||
queryFormulas: [],
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
dataSource: 'logs',
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: '', dataType: '', type: '', isColumn: false },
|
||||
filters: {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: 'f1',
|
||||
key: {
|
||||
key: 'severity_text',
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
isColumn: true,
|
||||
},
|
||||
op: '=',
|
||||
value: 'ERROR',
|
||||
},
|
||||
],
|
||||
},
|
||||
having,
|
||||
groupBy: [],
|
||||
orderBy: [],
|
||||
functions: [],
|
||||
legend: '',
|
||||
limit: null,
|
||||
stepInterval: 60,
|
||||
reduceTo: 'avg',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const FIRST = "service.name = 'frontend'";
|
||||
const SECOND = "service.name = 'cartservice'";
|
||||
|
||||
interface UrlQuery {
|
||||
builder?: { queryData?: { filter?: { expression?: string } }[] };
|
||||
}
|
||||
|
||||
test.describe('Query builder — URL handling', () => {
|
||||
for (const [label, raw] of Object.entries(MALFORMED)) {
|
||||
test(`TC-01 logs explorer survives ${label} and still runs`, async ({
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER.logs, { raw });
|
||||
|
||||
await expect(qb.query('A').filter.content()).toBeVisible();
|
||||
await qb.query('A').filter.type("severity_text = 'ERROR'");
|
||||
const fired = await qb.run();
|
||||
|
||||
expect(qb.spec(fired, 'A').filter?.expression).toBe(
|
||||
"severity_text = 'ERROR'",
|
||||
);
|
||||
expect(qb.urlQuery()).toMatchObject({ queryType: 'builder' });
|
||||
expect(qb.console.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [label, query] of Object.entries(PARTIAL)) {
|
||||
test(`TC-02 logs explorer repairs ${label}`, async ({ qb }) => {
|
||||
const loaded = await qb.openExplorer(EXPLORER.logs, { query });
|
||||
|
||||
expect(qb.spec(loaded, 'A').signal).toBe('logs');
|
||||
await expect
|
||||
.poll(() => qb.urlQuery())
|
||||
.toMatchObject({
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ queryName: 'A', dataSource: 'logs' }] },
|
||||
});
|
||||
expect(qb.console.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('TC-03 legacy filters and aggregate operator convert to v5 expressions', async ({
|
||||
qb,
|
||||
}) => {
|
||||
const loaded = await qb.openExplorer(EXPLORER.logs, {
|
||||
view: 'graph',
|
||||
query: legacyLogsQuery([]),
|
||||
});
|
||||
|
||||
const spec = qb.spec(loaded, 'A');
|
||||
expect(spec.filter?.expression).toMatch(/^severity_text\s*=\s*'ERROR'$/);
|
||||
expect(spec.aggregations?.[0]?.expression).toBe('count()');
|
||||
expect(spec).not.toHaveProperty('filters');
|
||||
await expect
|
||||
.poll(() => qb.query('A').filter.expression())
|
||||
.toMatch(/^severity_text\s*=\s*'ERROR'$/);
|
||||
expect(qb.console.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('TC-03 a legacy having as the old builder stored it still runs', async ({
|
||||
qb,
|
||||
}) => {
|
||||
const loaded = await qb.openExplorer(EXPLORER.logs, {
|
||||
view: 'graph',
|
||||
query: legacyLogsQuery([{ columnName: 'COUNT()', op: '>', value: 5 }]),
|
||||
});
|
||||
|
||||
expect(qb.spec(loaded, 'A').having?.expression).toBe('count() > 5');
|
||||
expect(qb.console.failedResponses).toEqual([]);
|
||||
});
|
||||
|
||||
for (const explorer of ['logs', 'traces'] as const) {
|
||||
test(`TC-04 ${explorer} explorer query follows back, forward and reload`, async ({
|
||||
authedPage: page,
|
||||
qb,
|
||||
}) => {
|
||||
await qb.openExplorer(EXPLORER[explorer]);
|
||||
const filter = qb.query('A').filter;
|
||||
const urlFilter = (): unknown =>
|
||||
(qb.urlQuery() as UrlQuery | null)?.builder?.queryData?.[0]?.filter
|
||||
?.expression;
|
||||
|
||||
await filter.type(FIRST);
|
||||
await qb.run();
|
||||
await filter.type(SECOND);
|
||||
await qb.run();
|
||||
|
||||
await page.goBack();
|
||||
await expect.poll(urlFilter).toBe(FIRST);
|
||||
await expect.poll(() => filter.expression()).toBe(FIRST);
|
||||
|
||||
await page.goForward();
|
||||
await expect.poll(urlFilter).toBe(SECOND);
|
||||
await expect.poll(() => filter.expression()).toBe(SECOND);
|
||||
|
||||
await filter.type("service.name = 'unsaved'");
|
||||
const sentBefore = qb.sent().length;
|
||||
await page.reload();
|
||||
await expect.poll(() => filter.expression()).toBe(SECOND);
|
||||
await expect
|
||||
.poll(() => qb.sent().slice(sentBefore).length)
|
||||
.toBeGreaterThan(0);
|
||||
expect(qb.spec(qb.sent().slice(sentBefore), 'A').filter?.expression).toBe(
|
||||
SECOND,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user