Compare commits

..

19 Commits

Author SHA1 Message Date
Vinícius Lourenço
b32e6689ef test(stories): add K8s drawer depth and pods log details stories 2026-11-09 21:39:13 -03:00
Manika Malhotra
5c067d75fc test(stories): cover the states the component migration changes 2026-11-09 21:39:13 -03:00
Srikanth Chekuri
4b1b3092d5 fix(querier): skip the cache for logs and traces queries with a limited group by (#13472)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- A logs or traces time series query with a group by and a limit ranks
its groups over the statement window. On a partial cache hit the gap
statement ranked over the gap, so the response was a union of the
winners of each piece, each with points for part of the window. A
narrower window served from a wider cached entry got the wider window's
winners.
- No piece of a window can stand in for the window, so such a query is
no longer served from the cache. Its fingerprint is empty, the same way
an AI builder query opts out today. Metrics are not affected, their
limit is applied after the full series set is read.
- Integration tests: a top-2 over a window whose first half is cached
must return the window's two winners with points for the whole window,
and a top-1 over the second half of a cached window must return that
half's winner. Both match the request with noCache.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#627

#### Additional Information

- The first commit adds the tests alone. CI ran them against main and
both failed. The second commit is the fix.
- This is the stop gap. Caching these queries exactly needs the ranking
summary of each piece, top-K totals with a threshold, so the merge can
rank with bounds and fall back when it cannot prove the top-N. That is a
design item, not a cache patch.
2026-11-09 20:33:56 +00:00
Srikanth Chekuri
d6017f88fd fix(querier): do not shift the gap of a timeShift query twice (#13470)
#### Description

- A timeShift query's window is moved into the shifted clock when the
query is built. On a partial cache hit the missing range comes from that
window, and the ranged gap query shifted it again, so the gap was read
one shift too far back, and the wrong points were served and written
back to the cache.
- The ranged query now takes the range as given. The spec copy already
carries the shift, so the gap statement reads the right data and the
postprocess shift moves it forward once.
- Integration test: a timeShift(3600) logs query with its first 30
minutes cached must return the same points for the next 20 minutes as
the request with noCache.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#625

#### Additional Information

- The first commit adds the test alone. CI ran it against main and it
failed with only that test failing in the suite. The second commit is
the fix.
2026-11-09 20:33:48 +00:00
Srikanth Chekuri
9d28014664 feat(querier): apply the having of a formula (#13452)
#### Description

The querier computes a formula in Go after its queries return, so a
formula having cannot run as SQL HAVING. It was accepted and ignored.

The having now filters the formula values in Go. It uses the having
grammar, validation, and messages of a builder query, with the formula
name and __result as the only references. A time series keeps the
points that match and drops a series with no points left. A scalar
result keeps the rows that match. The having runs before functions,
order, and limit, as HAVING does in SQL. A bad having fails the
request before any query runs.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#612

#### Additional Information

Assisted-by: Claude Opus 5.5
2026-11-09 20:33:22 +00:00
Srikanth Chekuri
1997901102 fix(querier): validate formula limits, raw order keys, and request types (#13426)
#### Description

- A negative formula `limit` now returns 400, as for builder queries.
Before, validation accepted it, and the series limit treated it as no
limit.
- An empty order key on a `raw`, `raw_stream`, or `trace` request now
returns 400. Before, validation accepted it. Aggregation requests keep
their own order key check, which lists the valid keys.
- A missing `requestType` returned `invalid request type: {}` and
suggested `timeseries`, which the decoder rejects. It now returns
`requestType is required`, with the valid request types as suggestions.
The decoder error for an unknown value lists the same set.
- Validation now accepts exactly what `RequestType.Enum` lists. The
spec, the decoder error, and validation read one list, so they cannot
drift apart.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#609

#### Additional Information

- Existing alerts should not be affected. Stored rules load without
validation, and the querier validates only in the `query_range` handler.
The UI does not send a negative formula limit or an empty order key.
- Part of https://github.com/SigNoz/nerve-pod/issues/428
2026-11-09 20:33:14 +00:00
Srikanth Chekuri
84268d00c8 docs(querier): add query_range examples for every builder function (#13475)
#### Description

- The query_range examples cover signals and request types but none of
the functions a builder query can carry, so a client has no place to
learn the argument shapes.
- Adds one example per function family: timeShift, fillZero, the four
threshold functions, runningDiff, ewma, median, cumulativeSum, log,
anomaly. Each description says what the function does to the series,
which arguments it takes, their units and their defaults.
- A test unmarshals every example into a `QueryRangeRequest` and
validates it, so an example cannot drift from the request type.
- The OpenAPI spec is regenerated in its own commit.
2026-11-09 20:32:41 +00:00
Ashwin Bhatkal
628e989bc0 fix(alerts): refresh the v3 rules list after rule changes (#13442)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Saving a rule, and cloning or renaming one from its details page, now
refreshes the v3 rules list instead of v2. The list page (#13322) and
`useListAllRules` (#13440) both read v3, so nothing reads the v2 list
anymore.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/718
2026-11-09 13:53:16 +00:00
Ashwin Bhatkal
74d272a945 fix(ai-assistant): read alert rules from the v3 list API (#13441)
#### Description

- The AI assistant's Alerts context picker now loads rules with one
`/api/v3/rules` request (`limit: 200`, the most the API returns per
call) instead of the deprecated `GET /api/v2/rules`. The Dashboards tab
next to it already loads one page (`limit: 1000`) the same way.
- Alert context chips read their names from the same cache entry.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/717
2026-11-09 13:20:39 +00:00
Ashwin Bhatkal
f8421e81e9 fix(planned-downtime): read alert rules from the v3 list API (#13440)
#### Description

- The planned downtime rule picker now reads `/api/v3/rules` through a
new `useListAllRules` hook instead of the deprecated `GET
/api/v2/rules`.
- The picker lists every rule, and v3 returns at most 200 per call, so
the hook fetches the remaining pages in parallel, like
`useChannelOptions` does for channels. Orgs with 200 rules or fewer make
one request.
- The planned downtime story now mocks `/api/v3/rules`.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/713
2026-11-09 12:45:49 +00:00
Ashwin Bhatkal
e938a10ad5 fix(home): read alert rules from the v3 list API (#13439)
#### Description

- `GET /api/v2/rules` is deprecated in favor of the paginated
`/api/v3/rules`, and #13158 removes it. This moves the home page alerts
widget to v3 ahead of that.
- The widget still shows firing rules first, then the most recently
updated others, up to 5. It asks v3 for the firing rules first and
fetches the latest rules only when fewer than 5 are firing, instead of
loading every rule.
- Clicking a rule now opens the overview with only `ruleId`, like the
alert list, triggered alerts and Metrics Explorer. The overview loads
the rule and builds its query from it, so the `compositeQuery` the old
link carried is not needed.
- The home page story now mocks `/api/v3/rules`.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/712
2026-11-09 11:52:40 +00:00
Ashwin Bhatkal
93cc1957d4 chore: make pulse-frontend the code owner of the list filter, views and alert rules folders (#13476)
#### Description

- `@SigNoz/pulse-frontend` now owns `components/ListFilter`,
`components/ListViews` and `hooks/listViews`. Only the dashboards list
and the alert rules list use them.
- It also owns `container/PlannedDowntime`, `hooks/alerts` and the home
page alert rules widget (`container/Home/AlertRules`).
- Until now these fell to the `/frontend/` catch-all, so changes to them
also needed `@SigNoz/frontend-maintainers`.
2026-11-09 11:26:04 +00:00
Swapnil Nakade
313ec2d657 chore: bump go version to 1.25 in github ci (#13480)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- bump go version in ci to 1.25

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-11-09 10:08:41 +00:00
Vinicius Lourenço
87bcd1b702 test(saved-views): cover behaviour the current tests miss (#13301)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

Stacked on #13287, so that one merges first.

The saved views tests touch about 98% of the lines, yet a lot of
behaviour could break without any of them failing. This adds tests for
that behaviour:

- The row link and Copy link follow the selected time range.
- Saving from the header: a failed update, blank names, pressing Enter
twice, keeping the view color, and saving the query that was run.
- The hover card while it loads, when it fails, and when it closes.
- Restoring the last used view, per source.
- The unsaved changes check and spec conversion against what the server
stores.

It also adds the first saved views e2e specs, run against a real
backend:

- Open, create, update, discard, delete and rename a view on the logs
explorer, and check the header is still clean after a reload.
- Row links and Copy link carry the time range, and a Ctrl/Cmd or middle
click opens the view in a new tab.

While adding the tests I found that renaming a view from the row menu
sent the list's copy of the view back as a full replacement, so a stale
list could undo someone else's edit. Rename now reads the stored view
first, and falls back to the list's copy when the user cannot read the
view.

#### Issues closed by this PR

Closes https://github.com/SigNoz/uforge-pod/issues/6397

#### Additional Information

- Each new test fails when the code it covers is broken on purpose.
- Jest: `pnpm jest src/container/SavedViews
src/utils/__tests__/buildExplorerNavigationUrl.test.ts`
- E2E, with the stack up (`pnpm env:start` in `tests/e2e`): `pnpm
playwright test tests/saved-views --project=chromium`
- Opening a view the user cannot read still sends the GET, from
`usePreferenceSync`, which has no read check. The server answers 403 and
the header shows "No view access".
2026-11-08 17:53:07 +00:00
Srikanth Chekuri
43cde62108 test(alertmanager): fix flaky TestAggrGroup timing and resolve checks (#12131)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
## Description

- TestAggrGroup failed four times this week in CI with "received batch
too early after 299.5ms" and "Expected aggregation group to be empty
after resolving alerts".
- The test timed batches with its own clock. The group resets its timer
before it calls notify, so two ticks can be under one group interval on
the test's clock by a millisecond. The test now reads the tick time the
group puts in the context, which is the clock the group itself uses.
- The empty check ran as soon as the batch arrived, but the group
deletes resolved alerts after notify returns. The test now waits for the
delete.
- Checks inside the notify callback use assert, not require, because the
callback runs on the group's goroutine.

## Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/5121
Closes https://github.com/SigNoz/engineering-pod/issues/5636

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-11-07 19:15:26 +00:00
Srikanth Chekuri
94367f4575 fix(querybuilder): warn on unknown keys outside the filter (#13421)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

`ResolveColumn` dropped the not-found warning from `Resolve`, so a group
by, select field, order by, or aggregation on an unknown key returned
data with no warning. The le bucket key the histogram rewrite adds does
not warn, and meter queries still report no warnings.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#600

#### Additional Information

Assisted-by: Claude Opus 5.5
2026-11-07 10:51:41 +00:00
Srikanth Chekuri
108b2d8694 fix(querybuilder): parse aggregation predicates and field names like filters (#13418)
#### Description

- Gracefully handle aggregation keys names
  - otherwise `sum(http.response.body-size)` became a subtraction
- `count_distinct(k8s.pod.labels.app.kubernetes.io/name)` a division,
and both returned null or 0 with no warning to agent.
- Parsing failure, a wrong argument count, or an argument that is not a
field name now returns 400, earlier 500.
- Each argument of an aggregation call now comes from the source text
before the SQL parse. Predicates go to the filter grammar. A value
argument must be one filter-grammar key, so keys with `-`, `/` and
`:type` resolve.
- rewriter parsed the whole expression with the chsql parser before the
filter grammar saw the predicate. Filter-only syntax in an `*If`
predicate (`EXISTS`, `NOT EXISTS`, `CONTAINS`, `NOT CONTAINS`) failed in
that parser and returned 500.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#599
2026-11-07 09:41:13 +00:00
Srikanth Chekuri
fa6fda338f fix(querybuilder): read an unknown body path in a column as a string (#13429)
#### Description

- A `body.` key that metadata does not report failed with 404 "field not
found" in a select, group by, order by, or aggregation. The logs
fallback kept it with no data type, and the JSON body read needs one. A
filter on the same key worked, because the operand types it.
- The fallback now types it as `bodyPath` does for a bare key: a string
path when no operand types it. Filter SQL does not change.
- `TestResolveColumn_UnknownKey` resolves an unknown key in a column for
every storage and field context and allows only invalid input as an
error. On main, only the logs JSON body `body` context failed.
- The body path parity integration test now covers the `body.` spelling,
and group by, `count_distinct`, and select.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#610

#### Additional Information

- The not-found warning for group by and select reaches the response
with #13421. #13421 also changes `ResolveColumn` to return warnings; the
second PR to merge updates the call in `resolve_test.go`.
2026-11-07 09:39:16 +00:00
Abhi kumar
d1f4503b40 test(query-builder): add e2e suite for the query builder (#13313)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Adds a host-agnostic `qb` driver
(`tests/e2e/drivers/query-builder.ts`) and ~110 query builder tests
across the logs, traces and metrics explorers, the V2 panel editor and
alerts. Assertions read the outgoing `query_range` body and the URL
query, never exact counts.
- Covers URL handling (malformed, partial, legacy shapes,
back/forward/reload), the filter editor (suggestions, quoting, IN lists,
nesting, validation, keyboard, recents, quick filters), metric-type
defaults, aggregations, add-ons per panel kind, multi-query and
formulas, trace operator, signal and query-type switching, cross-host
round trips (explorer → dashboard, alert save → reopen) and
run/cancel/error states.
- Each regression-driven case names the fix it guards (#13257, #12705,
#12496, #10048, #9947, #9732, #11208, #9637, #11035, #10100, #12755,
#9066, #8250).
- Seeds a gauge and a histogram per worker (golden metrics are all
sums); exports the seeder's retrying POST for that.
- Shared helper fixes found under load: `pickChannelByName` re-clicks
while the select is still closed; `holdQueryRange` tolerates a request
cancelled while held.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#662

#### Additional Information

- Stacked: #13315 (legacy having and limit fixes) → #13323 (added query
keeps its signal) → this PR. Merge in that order.
- Regression tests for both fixes (legacy having `COUNT() > 5`, negative
limit, added query's signal) fail against the unfixed app.
- Stable across 2 consecutive CI-mode runs on chromium (198 passed, 0
flaky). Meter source switching is not covered yet: the stack has no
meter metrics.
2026-11-06 20:25:12 +00:00
134 changed files with 7714 additions and 447 deletions

9
.github/CODEOWNERS vendored
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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;
/**

View File

@@ -11,8 +11,6 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
// generated API Instance
const generatedAPIAxiosInstance = axios.create({
baseURL: ENVIRONMENT.baseURL,
// The backend binds repeated keys (`states=a&states=b`); axios' default sends `states[]=`.
paramsSerializer: { indexes: null },
});
let generatedAPIQueryKeyHeaderContext: Record<string, unknown> | undefined;

View File

@@ -98,6 +98,15 @@ export const noSavedFiltersHandler = rest.get(
),
);
export const noSavedTracesFiltersHandler = rest.get(
'http://localhost/api/v2/quick_filters/traces',
(_req, res, ctx) =>
res(
ctx.status(200),
ctx.json(quickFiltersResponse(QuickfiltertypesSourceDTO.traces, [])),
),
);
export const loadingFiltersHandlers = [
rest.get('http://localhost/api/v2/quick_filters/logs', (_req, res, ctx) =>
res(ctx.delay('infinite')),

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { ComponentProps, ComponentType } from 'react';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import type { GlobalMockArgs } from '@/storybook/globals';
@@ -16,8 +17,9 @@ import {
queryBuilder,
selectedServiceQueryBuilder,
noSavedFiltersHandler,
noSavedTracesFiltersHandler,
} from './QuickFilters.stories.mocks';
import { QuickFiltersSource } from '../types';
import { FiltersType, QuickFiltersSource } from '../types';
const meta = {
title: 'Components/Quick Filters',
@@ -110,6 +112,29 @@ export const SelectedExpandedCheckbox: Story = {
},
};
/** The duration min/max row, which a checkbox-only config never reaches. */
export const DurationFilter: Story = {
args: {
quickFilterSource: QuickFiltersSource.TRACES_EXPLORER,
config: [
...checkboxConfig,
{
attributeKey: {
dataType: DataTypes.Float64,
key: 'duration_nano',
type: '',
},
defaultOpen: true,
title: 'Duration',
type: FiltersType.DURATION,
},
],
},
parameters: {
msw: { handlers: [noSavedTracesFiltersHandler, ...handlers] },
},
};
/**
* Every tooltip the panel renders, held open: the reveal on each truncated
* filter value. Nothing bounds those values, so the panel is answered with

View File

@@ -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

View File

@@ -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']);
});
});

View File

@@ -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', () => ({

View File

@@ -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');

View File

@@ -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} />

View 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,
};
}

View 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',
]);
});
});

View File

@@ -16,13 +16,42 @@ import {
} from './K8sBaseDetails.stories.mocks';
import type { K8sBaseDetailsProps } from './types';
import {
DrawerHistoryEntry,
getDrawerHistoryDepth,
pushDrawerHistory,
resetDrawerHistory,
useDrawerHistoryStore,
} from './useDrawerHistoryStore';
type DrawerArgs = PageStoryArgs<typeof drawerMocks> &
K8sBaseDetailsProps<unknown>;
K8sBaseDetailsProps<unknown> & { earlierSteps?: number };
const TRAIL: DrawerHistoryEntry[] = [
{
category: InfraMonitoringEntity.CLUSTERS,
label: 'mgmt',
queryKeyPrefix: 'cluster',
},
{
category: InfraMonitoringEntity.NAMESPACES,
label: 'generator',
queryKeyPrefix: 'namespace',
},
{
category: InfraMonitoringEntity.DEPLOYMENTS,
label: 'app-demo-loadgenerator',
queryKeyPrefix: 'deployment',
},
{
category: InfraMonitoringEntity.NODES,
label: NODE_NAME,
queryKeyPrefix: 'node',
},
].map(({ category, label, queryKeyPrefix }) => ({
params: { selectedItem: label, category },
category,
label,
entity: { name: label },
queryKeyPrefix,
}));
const route = `/infrastructure-monitoring/kubernetes?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=pods&${INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM}=${POD_UID}`;
@@ -33,19 +62,13 @@ const meta = {
decorators: [
(Story, context): JSX.Element => {
// The back control appears only for a resource opened from another
const depth = getDrawerHistoryDepth();
if (context.args.openedFromANode && depth === 0) {
pushDrawerHistory({
params: { selectedItem: NODE_NAME, category: InfraMonitoringEntity.NODES },
category: InfraMonitoringEntity.NODES,
label: NODE_NAME,
entity: { nodeName: NODE_NAME },
queryKeyPrefix: 'node',
const steps =
context.args.earlierSteps ?? (context.args.openedFromANode ? 1 : 0);
if (getDrawerHistoryDepth() !== steps) {
useDrawerHistoryStore.setState({
entries: TRAIL.slice(TRAIL.length - steps),
});
}
if (!context.args.openedFromANode && depth > 0) {
resetDrawerHistory();
}
return <Story />;
},
@@ -60,3 +83,19 @@ type Story = StoryObj<DrawerArgs>;
export const Default: Story = {
args: { ...podEntityConfig.details } as Partial<DrawerArgs> as DrawerArgs,
};
/** A pod drilled through a cluster, namespace, deployment and node, so the back control names the node. */
export const DeepTrail: Story = {
args: {
...podEntityConfig.details,
earlierSteps: 4,
} as Partial<DrawerArgs> as DrawerArgs,
};
/** A pod opened from the list, so the header shows the close control in place of back. */
export const OpenedFromTheList: Story = {
args: {
...podEntityConfig.details,
openedFromANode: false,
} as Partial<DrawerArgs> as DrawerArgs,
};

View File

@@ -3,11 +3,13 @@ import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRulesV3 } from 'api/generated/services/rules';
import type {
ListRulesV3200,
ListRulesV3Params,
RenderErrorResponseDTO,
RuletypesLabelPairDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
import { isUndefined } from 'lodash-es';
import type { AlertRule } from './types';
@@ -33,7 +35,18 @@ export function useAlertRulesData(
const hasLoggedEvent = useRef(false);
const rulesResponse = useListRulesV3(params, {
query: { keepPreviousData: true },
query: {
keepPreviousData: true,
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
queryFn: ({ signal }) =>
GeneratedAPIInstance<ListRulesV3200>({
url: '/api/v3/rules',
method: 'GET',
params,
paramsSerializer: { indexes: null },
signal,
}),
},
});
const rules = useMemo(

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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),
});
});
});

View File

@@ -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) =>

View File

@@ -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)));

View File

@@ -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');
});
});

View File

@@ -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' });

View File

@@ -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();

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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

View File

@@ -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,

View File

@@ -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(),

View 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']);
});
});

View 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,
});
}

View File

@@ -139,6 +139,35 @@ export const AwaitingClarification: Story = {
args: { agent: 'awaiting-clarification', contents: BRIEF },
};
/** The severity select open, including the free-text option the form's Select renders. */
export const ClarificationSelectOpen: Story = {
args: { agent: 'awaiting-clarification', contents: BRIEF },
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
// Open from the keyboard, and retry: the message list remounts its items
// while it measures the thread, which can close the popup again. The
// "Other" checkbox in the Notify field carries the same text, so assert
// on the popup's option role.
await waitFor(async () => {
const severityLabel = canvas.getByText('Severity');
const field = severityLabel.closest('div');
if (!field) {
throw new Error('Severity field is not in the clarification form');
}
within(field).getByRole('combobox').focus();
await userEvent.keyboard('{Enter}');
await screen.findByRole(
'option',
{ name: 'Other (type your own)' },
{ timeout: 1_000 },
);
}, untilLoaded);
},
};
/** Reopening the page on a thread that has not come back yet. */
export const LoadingThread: Story = {
args: { dataState: 'loading' },
@@ -231,6 +260,23 @@ export const NegativeFeedback: Story = {
},
};
/** Renaming a thread: the title input the row swaps in. */
export const ConversationRename: Story = {
play: async ({ canvasElement }): Promise<void> => {
await clickUntil(async () => {
const [actions] = await within(canvasElement).findAllByLabelText(
/conversation actions/i,
undefined,
untilLoaded,
);
return actions;
}, /^Rename$/);
await userEvent.click(screen.getByText('Rename'));
await within(canvasElement).findByDisplayValue(/checkout p99 regression/i);
},
};
/** What a conversation row offers: rename, a link to it, and archiving. */
export const ConversationActions: Story = {
play: async ({ canvasElement }): Promise<void> => {

View File

@@ -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) => {

View File

@@ -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}`,

View File

@@ -63,6 +63,20 @@ const expandConfigSections: Story['play'] = async ({ canvasElement }) => {
*/
export const Default: Story = {};
/** The ClickHouse query tab, including its info callout. */
export const ClickHouseQuery: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText('ClickHouse Query', undefined, {
timeout: 15000,
}),
);
await screen.findByText(/ClickHouse query agent skill/i, undefined, {
timeout: 15000,
});
},
};
/** The create route, seeding an unsaved panel of the chosen kind. */
export const NewPanel: Story = {
args: { panel: 'new' },
@@ -83,6 +97,21 @@ export const PieChartPanel: Story = {
args: { panel: 'traffic-share' },
};
/** A new area chart: the time series options with the area filled. */
export const AreaChartPanel: Story = {
args: { panel: 'new', newPanelKind: 'area-chart' },
};
/** A new histogram, where the display options are its buckets. */
export const HistogramPanel: Story = {
args: { panel: 'new', newPanelKind: 'histogram' },
};
/** A new text panel: a markdown body in place of the query builder. */
export const TextPanel: Story = {
args: { panel: 'new', newPanelKind: 'text' },
};
/** A list panel, where the config pane is the column editor. */
export const ListPanel: Story = {
args: { panel: 'recent-logs' },

View File

@@ -105,6 +105,19 @@ export const ChartAppearance: Story = {
},
};
/** Disconnect-values in threshold mode, with an invalid duration showing the inline error. */
export const ChartAppearanceDisconnectThreshold: Story = {
play: async (context) => {
await ChartAppearance.play?.(context);
await pickSegment('panel-editor-v2-span-gaps', 'threshold');
const input = await screen.findByTestId('panel-editor-v2-span-gaps-value');
await userEvent.clear(input);
await userEvent.type(input, '5x');
await screen.findByText(/enter a valid duration/i);
},
};
/** Dashed lines under a gradient fill, with the points drawn. */
export const ChartAppearanceDashedGradient: Story = {
play: async (context) => {

View File

@@ -191,6 +191,22 @@ export const TooltipsInViewPanelModal: Story = {
},
};
/** A list or table panel's search field, expanded and filled. */
export const PanelSearchExpanded: Story = {
play: async ({ canvasElement }) => {
// Both the table and the log list render a search trigger; expand the first.
const [trigger] = await within(canvasElement).findAllByTestId(
'panel-header-search-trigger',
{},
{ timeout: 15000 },
);
await userEvent.click(trigger);
const input = await screen.findByTestId('panel-header-search-input');
await userEvent.type(input, 'error');
},
};
/**
* The dashboard's own Actions menu, open: rename, clone, lock, full screen and
* delete, the menu the toolbar's Actions button carries.
@@ -243,6 +259,34 @@ export const SectionActionsMenu: Story = {
},
};
/** Delete dashboard, the confirm dialog the Actions menu opens. */
export const DeleteDashboardDialog: Story = {
play: async (context) => {
await ActionsMenu.play?.(context);
// The row stays inert until its authz checks resolve, so one click can
// land too early; keep clicking until the confirm dialog is up.
await waitFor(
async () => {
const item = screen.queryByTestId('dashboard-action-delete');
if (item) {
await userEvent.click(item);
}
screen.getByText('This action cannot be undone.');
},
{ timeout: 10000 },
);
},
};
/** Rename section, the title dialog a section menu opens. */
export const RenameSectionDialog: Story = {
play: async (context) => {
await SectionActionsMenu.play?.(context);
await userEvent.click(await screen.findByText('Rename section'));
await screen.findByRole('dialog', { name: /rename section/i });
},
};
const openNewPanelPicker = async (
canvasElement: HTMLElement,
): Promise<void> => {

View File

@@ -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(

View File

@@ -97,3 +97,20 @@ export const NavSettingsMenu: Story = {
await screen.findByRole('menu');
},
};
/** Impersonation mode: the warning banner above the page content. */
export const NoAuthBanner: Story = {
args: { banner: 'no-auth' },
play: async (): Promise<void> => {
await screen.findByTestId('no-auth-banner', undefined, { timeout: 10000 });
},
};
/** The command palette, opened with the same shortcut the shell listens for. */
export const CommandPalette: Story = {
play: async (): Promise<void> => {
await userEvent.keyboard('{Meta>}k{/Meta}');
await screen.findByPlaceholderText('Search…', undefined, { timeout: 10000 });
await screen.findByText('Go to Home');
},
};

View File

@@ -69,6 +69,28 @@ export const DetailsDrawerLogsTab: StoryObj<PodsArgs> = {
},
};
/** A log opened from the pod's logs tab, over the details drawer. */
export const DetailsDrawerLogDetails: StoryObj<PodsArgs> = {
args: { drawer: true },
play: async () => {
const drawer = within(
await screen.findByRole('dialog', {}, { timeout: 10000 }),
);
await userEvent.click(
await drawer.findByText('Logs', {}, { timeout: 10000 }),
);
const [line] = await drawer.findAllByText(
/handled request in/,
{},
{ timeout: 10000 },
);
await userEvent.click(line);
await screen.findByTestId('log-detail-drawer', {}, { timeout: 10000 });
},
};
/** The copy button beside the pod name in the drawer, which toasts the copy. */
export const PodNameCopiedToast: StoryObj<PodsArgs> = {
args: { drawer: true },

View File

@@ -117,6 +117,43 @@ export const ConnectAccount: Story = {
},
};
/** Azure's connect dialog, which the AWS regions form does not open. */
export const AzureConnectAccount: Story = {
args: { provider: 'azure', accounts: 0 },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/integrate now/i,
undefined,
untilLoaded,
),
);
await screen.findByRole('dialog', undefined, untilLoaded);
},
};
/** The GCP region combobox, opened from the connect drawer. */
export const GcpRegionDropdown: Story = {
args: { provider: 'gcp', accounts: 0 },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/integrate now/i,
undefined,
untilLoaded,
),
);
await userEvent.click(
await screen.findByTestId(
'gcp-deployment-region-select',
undefined,
untilLoaded,
),
);
await screen.findByRole('listbox');
},
};
/** The regions an already-connected account collects from. */
export const EditAccount: Story = {
play: async ({ canvasElement }): Promise<void> => {

View File

@@ -14,6 +14,7 @@ import {
type GroupSave,
MAPPING_GROUP_MAX,
spanMapperGroupsResponse,
spanMappersResponse,
wideConditionGroupsResponse,
} from './__story_mockdata__/attributeMapping';
@@ -28,6 +29,15 @@ export const wideConditionGroup = rest.get(
res(ctx.status(200), ctx.json(wideConditionGroupsResponse())),
);
export const groupMappers = rest.get(
'http://localhost/api/v1/span_mapper_groups/:groupId/span_mappers',
(req, res, ctx) =>
res(
ctx.status(200),
ctx.json(spanMappersResponse(String(req.params.groupId))),
),
);
export const attributeMappingMocks = defineStoryMocks({
controls: {
groups: countControl('Mapping groups', {

View File

@@ -7,6 +7,7 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import LLMObservabilityPage from '../../index';
import {
attributeMappingMocks,
groupMappers,
wideConditionGroup,
} from './AttributeMapping.stories.mocks';
@@ -79,6 +80,29 @@ export const GroupFormDrawer: Story = {
},
};
/** The drawer that adds a mapping to the first group. */
export const AddMappingDrawer: Story = {
parameters: { msw: { handlers: [groupMappers] } },
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
const [firstGroup] = await canvas.findAllByTestId(
/^group-expand-/,
{},
{ timeout: 15000 },
);
await userEvent.click(firstGroup);
await userEvent.click(
await canvas.findByRole(
'button',
{ name: /add mapping/i },
{ timeout: 15000 },
),
);
await screen.findByText('New custom mapping');
},
};
/**
* A group toggled off and saved: the toast announcing the saved changes, raised
* once the PATCH and the list refetch answer. Set Save changes to `error`,

View File

@@ -5,10 +5,15 @@
import type {
ListSpanMapperGroups200,
ListSpanMappers200,
SpantypesSpanMapperGroupConditionKeyDTO,
SpantypesSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SpantypesSpanMapperOriginDTO } from 'api/generated/services/sigNoz.schemas';
import {
SpantypesFieldContextDTO,
SpantypesSpanMapperOperationDTO,
SpantypesSpanMapperOriginDTO,
} from 'api/generated/services/sigNoz.schemas';
const ORG_ID = 'org-signoz';
@@ -72,6 +77,38 @@ export const spanMapperGroupsResponse = (
data: { items: GROUPS.slice(0, count).map(group) },
});
/** The mappings an expanded group lists, which the groups response does not carry. */
export const spanMappersResponse = (groupId: string): ListSpanMappers200 => ({
status: 'success',
data: {
items: [
{
id: `${groupId}-mapper-1`,
groupId,
name: 'gen_ai.request.model',
enabled: true,
origin: SpantypesSpanMapperOriginDTO.user,
fieldContext: SpantypesFieldContextDTO.attribute,
config: {
sources: [
{
key: 'langchain.model',
context: SpantypesFieldContextDTO.attribute,
operation: SpantypesSpanMapperOperationDTO.copy,
priority: 0,
enabled: true,
},
],
},
createdAt: '2026-07-18T11:20:00Z',
createdBy: 'anna@signoz.io',
updatedAt: '2026-08-05T08:40:00Z',
updatedBy: 'anna@signoz.io',
},
],
},
});
const MANY_ATTRIBUTES = [
'langchain.model',
'langchain.prompt_tokens',

View File

@@ -39,6 +39,34 @@ export const NothingPriced: Story = {
args: { rules: 0 },
};
/** Models seen in spans that no rule prices yet. */
export const UnpricedModels: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'tab',
{ name: /unpriced models/i },
{ timeout: 15000 },
),
);
await screen.findByRole('tab', { name: /unpriced models/i, selected: true });
},
};
/** Creating a rule: the blank drawer, with the provider select enabled. */
export const ModelCostDrawerCreate: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: /add model cost/i },
{ timeout: 15000 },
),
);
await screen.findByTestId('drawer-provider-select');
},
};
/**
* The first pricing rule's menu, open over the table: edit the rule or drop it.
*/

View File

@@ -116,6 +116,16 @@ export const AddProcessor: Story = {
},
};
/** Severity Parser is the type whose mapping fields are CSV inputs. */
export const AddSeverityParserProcessor: Story = {
play: async (context): Promise<void> => {
await AddProcessor.play?.(context);
await userEvent.click(await screen.findByText('Grok'));
await userEvent.click(await screen.findByTitle('Severity Parser'));
await screen.findByText('Parse Severity Value From');
},
};
/**
* The logs a pipeline matches, over the interval the preview asks for, with the
* simulation of its processors one click away.

View File

@@ -106,6 +106,21 @@ export const RawFormat: Story = {
args: { format: 'raw' },
};
/** The format popover: the format list, the max-lines stepper, and the font-size control. */
export const FormatMenuOpen: Story = {
args: { format: 'raw' },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'periscope-btn-format-options',
undefined,
untilLoaded,
),
);
await screen.findByTestId('max-lines-per-row', undefined, untilLoaded);
},
};
/** An explorer with no configured quick filters. */
export const NoQuickFilters: Story = {
args: { quickFilters: 0 },

View File

@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { MessagingQueuesViewTypeOptions } from 'pages/MessagingQueues/MessagingQueuesUtils';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -98,6 +98,21 @@ export const DropRate: Story = {
args: { view: MessagingQueuesViewTypeOptions.DropRate },
};
/** The custom-interval input inside the evaluation-time dropdown. */
export const DropRateIntervalOpen: Story = {
args: { view: MessagingQueuesViewTypeOptions.DropRate },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText('10ms', undefined, untilLoaded),
);
await screen.findByPlaceholderText(
'Enter custom time (ms)',
undefined,
untilLoaded,
);
},
};
/** The broker, producer, consumer and JVM metrics kafka itself reports. */
export const MetricView: Story = {
args: { view: MessagingQueuesViewTypeOptions.MetricPage },

View File

@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ROUTES from 'constants/routes';
import { renderAtRoute } from '@/storybook/renderAtRoute';
import { screen, userEvent, within } from 'storybook/test';
import { screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -76,3 +76,26 @@ export const DownloadMenu: Story = {
await screen.findByRole('menu');
},
};
/** The apdex threshold popover, opened from the overview's settings control. */
export const ApDexSettingsOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
const trigger = await waitFor(
() => {
const found = canvasElement.querySelector<HTMLElement>(
'.ap-dex-settings-popover-content button',
);
if (!found) {
throw new Error('Apdex settings control is not rendered');
}
return found;
},
{ timeout: 15000 },
);
await userEvent.click(trigger);
await screen.findByText(/apdex threshold/i);
},
};

View File

@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -35,3 +36,20 @@ export const ExpiredLink: Story = {
// The deliberate 410 is the state under test.
parameters: { allowConsoleErrors: true },
};
/** The mismatch callout, after the two password fields disagree and blur. */
export const PasswordMismatch: Story = {
play: async ({ canvasElement }): Promise<void> => {
const password = await screen.findByTestId('password', undefined, {
timeout: 15000,
});
const confirm = await screen.findByPlaceholderText(
/confirm your new password/i,
);
await userEvent.type(password, 'password123');
await userEvent.type(confirm, 'password456');
await userEvent.click(canvasElement);
await screen.findByText(/passwords don't match/i);
},
};

View File

@@ -32,6 +32,7 @@ import {
timeSeriesPoints,
} from '@/storybook/msw/__story_mockdata__/queryRange';
import { traceFunnelsResponse } from '../../TracesModulePage/stories/__story_mockdata__/traces';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
import {
focusSpanIndex,
@@ -221,6 +222,11 @@ export const traceDetailsMocks = defineStoryMocks({
}),
),
rest.get(
'http://localhost/api/v1/trace-funnels/list',
response.json(() => traceFunnelsResponse(3)),
),
rest.post(
'http://localhost/api/v1/span_percentile',
response.json(spanPercentilesResponse),

View File

@@ -248,6 +248,72 @@ export const SpanPercentileOpen: Story = {
},
};
/** Spans the backend never received, shown as the missing-spans callout. */
export const MissingSpans: Story = {
args: { missingSpans: true },
play: async (): Promise<void> => {
await screen.findByText(
/this trace has missing spans/i,
undefined,
untilLoaded,
);
},
};
/** The analytics panel the header toggle opens. */
export const AnalyticsOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: 'Analytics' },
untilLoaded,
),
);
await screen.findByTestId('trace-analytics-panel', undefined, untilLoaded);
},
};
/** The download progress panel, held open by an export that never answers. */
export const TraceDownload: Story = {
args: { exportState: 'loading' },
play: async ({ canvasElement }): Promise<void> => {
await startDownload(canvasElement);
await screen.findByTestId('trace-download-panel', undefined, untilLoaded);
},
};
const openAddSpanToFunnel = async (
canvasElement: HTMLElement,
): Promise<void> => {
const [button] = await within(canvasElement).findAllByTestId(
'span-add-to-funnel-btn',
undefined,
untilLoaded,
);
await fireEvent.click(button);
await screen.findByText('Add span to funnel', undefined, untilLoaded);
};
/** The funnel picker a span row's add-to-funnel action opens. */
export const AddSpanToFunnel: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openAddSpanToFunnel(canvasElement);
},
};
/** The name field behind "Create new funnel" in that picker. */
export const AddSpanToFunnelCreateStep: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openAddSpanToFunnel(canvasElement);
await userEvent.click(
await screen.findByRole('button', { name: /create new funnel/i }),
);
await screen.findByTestId('create-funnel-name-input');
},
};
/** The span has no logs of its own but its trace does, so the Logs tab points to the trace's. */
export const SpanPanelTraceOnlyLogs: Story = {
args: { traceOnlyLogs: true },

View File

@@ -123,6 +123,43 @@ export const Funnels: Story = {
args: { tab: 'funnels' },
};
/** The name field for a funnel that does not exist yet. */
export const CreateFunnel: Story = {
args: { tab: 'funnels' },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: /new funnel/i },
untilLoaded,
),
);
await screen.findByRole('dialog', { name: /create new funnel/i });
},
};
/** The rename field for a funnel already on the list. */
export const RenameFunnel: Story = {
args: { tab: 'funnels' },
play: async ({ canvasElement }): Promise<void> => {
const action = await waitFor(() => {
const found = canvasElement.querySelector<HTMLElement>(
'.funnel-item__action-icon',
);
if (!found) {
throw new Error('Funnel row actions are not rendered');
}
return found;
}, untilLoaded);
await userEvent.click(action);
await userEvent.click(await screen.findByRole('button', { name: 'Rename' }));
await screen.findByRole('dialog', { name: /rename funnel/i });
},
};
/**
* A fresh workspace: nothing ingested in the window and no quick filters
* configured yet, so the table and the filter panel both show their empty

View File

@@ -64,6 +64,70 @@ export const LongMultilineToast: Story = {
},
};
/** A request still in flight: the loading toast stays until the promise settles. */
export const PromiseLoadingToast: Story = {
args: { state: 'promise-loading-toast' },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
within(canvasElement).getByTestId('show-feedback-toast'),
);
await expect(
await screen.findByText('Enabling the alert rule'),
).toBeVisible();
},
};
/** A failed request: the rejected toast keeps a Dismiss action, stacked top-center. */
export const PromiseErrorToast: Story = {
args: { state: 'promise-error-toast' },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
within(canvasElement).getByTestId('show-feedback-toast'),
);
await expect(
await screen.findByText('Could not clone the panel'),
).toBeVisible();
await screen.findByRole('button', { name: 'Dismiss' });
},
};
/** A warning that stays up, matching invites that only partly succeed. */
export const WarningToast: Story = {
args: { state: 'warning-toast' },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
within(canvasElement).getByTestId('show-feedback-toast'),
);
await expect(await screen.findByText('Some invites failed')).toBeVisible();
},
};
/** An info toast pinned to the bottom-right stack, as export cancellation does. */
export const InfoToast: Story = {
args: { state: 'info-toast' },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
within(canvasElement).getByTestId('show-feedback-toast'),
);
await expect(await screen.findByText('Export cancelled')).toBeVisible();
},
};
/** An error toast with a description and a Dismiss action, bottom-right. */
export const DangerWithDescription: Story = {
args: { state: 'danger-description-toast' },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
within(canvasElement).getByTestId('show-feedback-toast'),
);
await expect(
await screen.findByText('Failed to update account settings'),
).toBeVisible();
await screen.findByText(/does not have permission/i);
await screen.findByRole('button', { name: 'Dismiss' });
},
};
/** Mutation: the inline application alert preserves its action affordance. */
export const InlineAlert: Story = {
args: { state: 'inline-alert' },

View File

@@ -10,6 +10,11 @@ export type FeedbackState =
| 'success-toast'
| 'actionable-error-toast'
| 'long-toast'
| 'promise-loading-toast'
| 'promise-error-toast'
| 'warning-toast'
| 'info-toast'
| 'danger-description-toast'
| 'inline-alert'
| 'confirmation'
| 'toast-over-confirmation';
@@ -21,6 +26,10 @@ interface FeedbackFixtureProps {
const longMessage =
'We could not apply the retention policy because the selected storage tier is unavailable. Review the workspace settings, then try again.';
const pendingSave = new Promise<void>(() => undefined);
const dismissAction = { label: 'Dismiss', onClick: (): void => undefined };
function FeedbackFixture({ state }: FeedbackFixtureProps): JSX.Element {
const [isConfirmationOpen, setIsConfirmationOpen] = useState(false);
@@ -37,13 +46,62 @@ function FeedbackFixture({ state }: FeedbackFixtureProps): JSX.Element {
return;
}
if (state === 'promise-loading-toast') {
toast.promise(pendingSave, {
loading: 'Enabling the alert rule',
success: 'Alert rule enabled',
error: 'Could not enable the alert rule',
});
return;
}
if (state === 'promise-error-toast') {
toast.promise(Promise.reject(new Error('failed')), {
loading: 'Cloning the panel',
success: 'Panel cloned',
error: { message: 'Could not clone the panel', action: dismissAction },
position: 'top-center',
duration: Infinity,
});
return;
}
if (state === 'warning-toast') {
toast.warning('Some invites failed', { duration: Infinity });
return;
}
if (state === 'info-toast') {
toast.info('Export cancelled', {
position: 'bottom-right',
duration: Infinity,
});
return;
}
if (state === 'danger-description-toast') {
toast.error('Failed to update account settings', {
description:
'The service account does not have permission to change this connection.',
action: dismissAction,
position: 'bottom-right',
duration: Infinity,
});
return;
}
toast.error(longMessage, { duration: Infinity });
};
const isToastStory =
state === 'success-toast' ||
state === 'actionable-error-toast' ||
state === 'long-toast';
state === 'long-toast' ||
state === 'promise-loading-toast' ||
state === 'promise-error-toast' ||
state === 'warning-toast' ||
state === 'info-toast' ||
state === 'danger-description-toast';
const confirmationTitle = 'Delete environment';
return (

View File

@@ -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,

View File

@@ -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))

View File

@@ -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()

View File

@@ -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,

View 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},
},
},
},
}),
),
}
}

View 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())
})
}
}

View File

@@ -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 (

View File

@@ -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
}

View File

@@ -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()}

View File

@@ -425,6 +425,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 +666,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 +722,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)

View File

@@ -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

View File

@@ -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 {
@@ -922,32 +930,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{

View File

@@ -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')
}

View 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
}

View 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))
})
}
}

View File

@@ -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

View File

@@ -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.

View 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)
}
})
}
}
}

View File

@@ -229,8 +229,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 +264,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 +281,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 +307,7 @@ func (b *auditQueryStatementBuilder) buildListQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}
@@ -321,8 +324,9 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
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 {
@@ -342,10 +346,11 @@ 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)
@@ -354,10 +359,11 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
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)
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))
}
@@ -443,7 +449,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 +467,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 +485,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 +500,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 +559,7 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}

View File

@@ -337,8 +337,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 +378,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 +398,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 +425,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),
}
@@ -441,8 +444,9 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
) (*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,10 +470,11 @@ 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)))
@@ -479,7 +484,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
// 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 +492,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))
}
@@ -589,7 +595,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 +616,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 +638,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 +656,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 +664,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 +730,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),
}

View File

@@ -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
}

View File

@@ -320,7 +320,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 +545,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 +583,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(

View File

@@ -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,
},

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -362,8 +362,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 +383,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 +408,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 +435,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}
@@ -569,8 +572,9 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
) (*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,10 +600,11 @@ 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))
@@ -608,7 +613,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
// 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 +621,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))
}
@@ -714,7 +720,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 +741,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 +767,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 +784,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 +792,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 +858,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}

View File

@@ -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")},
},
},
}

View File

@@ -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
}

View File

@@ -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)}}

View File

@@ -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)
})

View File

@@ -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)
}

View File

@@ -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)",

View File

@@ -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)
})

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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()...))
}
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View 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';

Some files were not shown because too many files have changed in this diff Show More