mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-12 11:20:42 +00:00
Compare commits
15 Commits
feat/distr
...
fix/genera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ade26dcae5 | ||
|
|
b8e7d02b4b | ||
|
|
f02b5521c6 | ||
|
|
4b1b3092d5 | ||
|
|
d6017f88fd | ||
|
|
9d28014664 | ||
|
|
1997901102 | ||
|
|
84268d00c8 | ||
|
|
628e989bc0 | ||
|
|
74d272a945 | ||
|
|
f8421e81e9 | ||
|
|
e938a10ad5 | ||
|
|
93cc1957d4 | ||
|
|
313ec2d657 | ||
|
|
d7ef3b0a67 |
9
.github/CODEOWNERS
vendored
9
.github/CODEOWNERS
vendored
@@ -209,6 +209,10 @@ go.mod @therealpandey
|
||||
/frontend/src/types/api/alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/providers/Alert.tsx @SigNoz/pulse-frontend
|
||||
/frontend/src/constants/alerts.ts @SigNoz/pulse-frontend
|
||||
/frontend/src/container/PlannedDowntime/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/Home/AlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/Home/__tests__/AlertRules.test.tsx @SigNoz/pulse-frontend
|
||||
|
||||
## Notification Channels
|
||||
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
|
||||
@@ -217,6 +221,11 @@ go.mod @therealpandey
|
||||
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
|
||||
|
||||
## List filter + views (dashboards and alerts lists)
|
||||
/frontend/src/components/ListFilter/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/ListViews/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/listViews/ @SigNoz/pulse-frontend
|
||||
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
2
.github/workflows/build-community.yaml
vendored
2
.github/workflows/build-community.yaml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
GO_NAME: signoz-community
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: community-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
|
||||
2
.github/workflows/build-enterprise.yaml
vendored
2
.github/workflows/build-enterprise.yaml
vendored
@@ -89,7 +89,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
GO_BUILD_CONTEXT: ./cmd/enterprise
|
||||
|
||||
2
.github/workflows/build-staging.yaml
vendored
2
.github/workflows/build-staging.yaml
vendored
@@ -95,7 +95,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: staging-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
GO_BUILD_CONTEXT: ./cmd/enterprise
|
||||
|
||||
20
.github/workflows/goci.yaml
vendored
20
.github/workflows/goci.yaml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_TEST_CONTEXT: ./...
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
fmt:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
lint:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
deps:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
semconv-generated:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: check-semconv-generated-files
|
||||
run: make semconv-check
|
||||
build:
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: qemu-install
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: aarch64-install
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: generate-openapi
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate openapi
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: generate-authz
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate authz
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: generate-web-settings
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config web-settings
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: generate-transaction-groups
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config transaction-groups
|
||||
|
||||
4
.github/workflows/gor-signoz-community.yaml
vendored
4
.github/workflows/gor-signoz-community.yaml
vendored
@@ -66,7 +66,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: cross-compilation-tools
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
|
||||
# copy the caches from build
|
||||
- name: get-sha
|
||||
|
||||
4
.github/workflows/gor-signoz.yaml
vendored
4
.github/workflows/gor-signoz.yaml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: cross-compilation-tools
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
|
||||
# copy the caches from build
|
||||
- name: get-sha
|
||||
|
||||
@@ -4526,99 +4526,6 @@ components:
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesDistributionAxes:
|
||||
properties:
|
||||
x:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionXAxis'
|
||||
description: The bucket axis.
|
||||
"y":
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionYAxis'
|
||||
description: The count axis.
|
||||
type: object
|
||||
DashboardtypesDistributionChartAppearance:
|
||||
properties:
|
||||
percentiles:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionPercentiles'
|
||||
description: Percentile markers drawn across the bucket axis.
|
||||
stack:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesStackMode'
|
||||
description: 'How groups combine in a bucket: normal (default) stacks them,
|
||||
none draws them side by side, percent stacks them as shares of the bucket.'
|
||||
required:
|
||||
- stack
|
||||
type: object
|
||||
DashboardtypesDistributionPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionAxes'
|
||||
description: The X (bucket) axis range and Y (count) axis scale.
|
||||
chartAppearance:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionChartAppearance'
|
||||
description: Stacking and percentile markers.
|
||||
formatting:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
description: Unit and decimal precision of the bucket bounds; counts take
|
||||
no unit.
|
||||
legend:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesLegend'
|
||||
description: Legend placement and series colors.
|
||||
visualization:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
description: The time range the bucket counts are summed over.
|
||||
type: object
|
||||
DashboardtypesDistributionPercentiles:
|
||||
properties:
|
||||
enabled:
|
||||
description: Whether percentile markers are drawn. Defaults to true.
|
||||
type: boolean
|
||||
values:
|
||||
description: The percentiles to mark, each in (0, 100), at most 5, no duplicates.
|
||||
Defaults to [50, 90, 99].
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- enabled
|
||||
- values
|
||||
type: object
|
||||
DashboardtypesDistributionXAxis:
|
||||
properties:
|
||||
max:
|
||||
description: Buckets wholly above this value are hidden. Null shows them
|
||||
all.
|
||||
nullable: true
|
||||
type: number
|
||||
min:
|
||||
description: Buckets wholly below this value are hidden. Null shows them
|
||||
all.
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesDistributionYAxis:
|
||||
properties:
|
||||
scale:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionYScale'
|
||||
description: 'How counts are spaced: linear (default) or log.'
|
||||
required:
|
||||
- scale
|
||||
type: object
|
||||
DashboardtypesDistributionYScale:
|
||||
enum:
|
||||
- linear
|
||||
- log
|
||||
type: string
|
||||
DashboardtypesDynamicVariableSignal:
|
||||
enum:
|
||||
- traces
|
||||
@@ -5389,7 +5296,6 @@ components:
|
||||
mapping:
|
||||
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/DistributionPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
|
||||
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
@@ -5412,7 +5318,6 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -5427,7 +5332,6 @@ components:
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
- signoz/DistributionPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -5459,21 +5363,6 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
description: The panel kind.
|
||||
enum:
|
||||
- signoz/DistributionPanel
|
||||
type: string
|
||||
spec:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionPanelSpec'
|
||||
description: The options for this panel kind. {} accepts every default.
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -5627,9 +5516,9 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPlugin'
|
||||
description: 'The visualization: kind is one of signoz/TimeSeriesPanel,
|
||||
signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel,
|
||||
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel,
|
||||
signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind''s
|
||||
options ({} accepts every default).'
|
||||
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel
|
||||
or signoz/HeatmapPanel, and spec holds that kind''s options ({} accepts
|
||||
every default).'
|
||||
queries:
|
||||
description: The queries the panel runs. Use exactly one entry for a data
|
||||
panel and [] for signoz/TextPanel. To run several named queries or formulas,
|
||||
@@ -5765,8 +5654,8 @@ components:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5RequestType'
|
||||
description: 'The result shape the panel requests: time_series for time
|
||||
series, bar, area and histogram panels, scalar for number, table and pie
|
||||
panels, raw for list panels, heatmap for heatmap panels, distribution
|
||||
for distribution panels, trace for trace panels.'
|
||||
panels, raw for list panels, heatmap for heatmap panels, trace for trace
|
||||
panels.'
|
||||
spec:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesQuerySpec'
|
||||
@@ -5923,9 +5812,9 @@ components:
|
||||
(builder query over AI observability traces), signoz/CompositeQuery (several
|
||||
named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL
|
||||
or signoz/TraceOperator, and spec holds that kind''s query. Pie and table
|
||||
panels reject signoz/PromQLQuery; heatmap and distribution panels reject
|
||||
signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only
|
||||
signoz/BuilderQuery and signoz/AIBuilderQuery.'
|
||||
panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery
|
||||
and signoz/TraceOperator; list panels accept only signoz/BuilderQuery
|
||||
and signoz/AIBuilderQuery.'
|
||||
required:
|
||||
- plugin
|
||||
type: object
|
||||
@@ -10342,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.
|
||||
@@ -32840,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:
|
||||
|
||||
@@ -11,7 +11,7 @@ const config: Config.InitialOptions = {
|
||||
coverageReporters: ['text', 'cobertura', 'html', 'json-summary'],
|
||||
collectCoverageFrom: ['src/**/*.{ts,tsx}'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
|
||||
modulePathIgnorePatterns: ['<rootDir>/dist/'],
|
||||
modulePathIgnorePatterns: ['dist'],
|
||||
moduleNameMapper: {
|
||||
'\\.(png|jpg|jpeg|gif|svg|webp|avif|ico|bmp|tiff)$':
|
||||
'<rootDir>/__mocks__/fileMock.ts',
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,8 @@ 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;
|
||||
|
||||
@@ -21,14 +21,15 @@ import {
|
||||
useListDashboardsForUserV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
getListRulesQueryKey,
|
||||
useListRules,
|
||||
getListRulesV3QueryKey,
|
||||
useListRulesV3,
|
||||
} from 'api/generated/services/rules';
|
||||
import type {
|
||||
DashboardtypesListedDashboardForUserV2DTO,
|
||||
ListDashboardsForUserV2200,
|
||||
ListDashboardsForUserV2Params,
|
||||
ListRules200,
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useQueryService } from 'hooks/useQueryService';
|
||||
@@ -179,6 +180,9 @@ const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
|
||||
*/
|
||||
const DASHBOARD_LIST_PARAMS: ListDashboardsForUserV2Params = { limit: 1000 };
|
||||
|
||||
/** Same as dashboards. 200 is the most the rules list returns per call. */
|
||||
const ALERT_RULE_LIST_PARAMS: ListRulesV3Params = { limit: 200 };
|
||||
|
||||
function dashboardTitle(
|
||||
dashboard: DashboardtypesListedDashboardForUserV2DTO,
|
||||
): string {
|
||||
@@ -755,7 +759,7 @@ export default function ChatInput({
|
||||
data: alertsResponse,
|
||||
isLoading: isAlertsLoading,
|
||||
isError: isAlertsError,
|
||||
} = useListRules({
|
||||
} = useListRulesV3(ALERT_RULE_LIST_PARAMS, {
|
||||
query: {
|
||||
enabled: isContextPickerOpen && activeContextCategory === 'Alerts',
|
||||
staleTime: Infinity,
|
||||
@@ -804,10 +808,10 @@ export default function ChatInput({
|
||||
}
|
||||
}
|
||||
if (ctx.type === 'alert' && ctx.resourceId) {
|
||||
const cached = queryClient.getQueryData<ListRules200>(
|
||||
getListRulesQueryKey(),
|
||||
const cached = queryClient.getQueryData<ListRulesV3200>(
|
||||
getListRulesV3QueryKey(ALERT_RULE_LIST_PARAMS),
|
||||
);
|
||||
const rule = cached?.data?.find((r) => r.id === ctx.resourceId);
|
||||
const rule = cached?.data.rules.find((r) => r.id === ctx.resourceId);
|
||||
if (rule?.alert) {
|
||||
return rule.alert;
|
||||
}
|
||||
@@ -835,8 +839,8 @@ export default function ChatInput({
|
||||
value: dashboardTitle(dashboard),
|
||||
})) ?? [],
|
||||
Alerts:
|
||||
alertsResponse?.data
|
||||
?.filter((alertRule) => Boolean(alertRule.alert))
|
||||
alertsResponse?.data.rules
|
||||
.filter((alertRule) => Boolean(alertRule.alert))
|
||||
.map((alertRule) => ({
|
||||
id: alertRule.id,
|
||||
value: alertRule.alert,
|
||||
@@ -1059,6 +1063,7 @@ export default function ChatInput({
|
||||
color="secondary"
|
||||
size="sm"
|
||||
id={`ai-context-tab-${category}`}
|
||||
testId={`ai-context-tab-${category}`}
|
||||
// Single stable panel id shared by every tab: only the
|
||||
// active category's tabpanel is rendered, so per-category
|
||||
// `aria-controls` ids would point at nonexistent nodes
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useListDashboardsForUserV2: (): unknown => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useQueryService', () => ({
|
||||
useQueryService: (): unknown => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useSpeechRecognition', () => ({
|
||||
useSpeechRecognition: (): unknown => ({
|
||||
isListening: false,
|
||||
isSupported: false,
|
||||
permission: 'prompt',
|
||||
start: jest.fn(),
|
||||
discard: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
|
||||
useAIAssistantAnalyticsContext: (): unknown => ({
|
||||
threadId: undefined,
|
||||
page: '/',
|
||||
mode: 'sidepane',
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import ChatInput from '../ChatInput';
|
||||
|
||||
describe('ChatInput — alerts context picker', () => {
|
||||
it('lists the rules from one v3 rules request', async () => {
|
||||
const [first, second] = alertRulesFixture;
|
||||
const requests: string[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
requests.push(req.url.search);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response([first, second], req.url.searchParams)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ChatInput onSend={jest.fn()} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
|
||||
await userEvent.click(await screen.findByTestId('ai-context-tab-Alerts'));
|
||||
|
||||
await expect(screen.findByText(first.alert)).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText(second.alert)).toBeInTheDocument();
|
||||
expect(requests).toStrictEqual(['?limit=200']);
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,12 @@ jest.mock('api/generated/services/dashboard', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
|
||||
getListRulesQueryKey: (): string[] => ['rules'],
|
||||
useListRulesV3: (): unknown => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
getListRulesV3QueryKey: (): string[] => ['rules'],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useQueryService', () => ({
|
||||
|
||||
@@ -18,7 +18,7 @@ import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
|
||||
import './styles.scss';
|
||||
import {
|
||||
invalidateGetRuleByID,
|
||||
invalidateListRules,
|
||||
invalidateListRulesV3,
|
||||
} from 'api/generated/services/rules';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
@@ -137,7 +137,7 @@ function Footer(): JSX.Element {
|
||||
{
|
||||
onSuccess: () => {
|
||||
void invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
void invalidateListRules(queryClient);
|
||||
void invalidateListRulesV3(queryClient);
|
||||
|
||||
toast.success('Alert rule updated successfully');
|
||||
safeNavigate('/alerts');
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { AppLink } from 'lib/router/AppLink';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import beaconUrl from '@/assets/Icons/beacon.svg';
|
||||
|
||||
import { getItemIcon } from '../constants';
|
||||
import { useHomeAlertRules } from './useHomeAlertRules';
|
||||
|
||||
export default function AlertRules({
|
||||
onUpdateChecklistDoneItem,
|
||||
@@ -28,49 +26,19 @@ export default function AlertRules({
|
||||
loadingUserPreferences: boolean;
|
||||
}): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const [rulesExist, setRulesExist] = useState(false);
|
||||
|
||||
const [sortedAlertRules, setSortedAlertRules] = useState<RuletypesRuleDTO[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const location = useAppLocation();
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
// Fetch Alerts
|
||||
const {
|
||||
data: alerts,
|
||||
isError,
|
||||
isLoading,
|
||||
} = useListRules({
|
||||
query: { cacheTime: 0 },
|
||||
});
|
||||
const { rules: alertRules, isError, isLoading } = useHomeAlertRules();
|
||||
const rulesExist = alertRules.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const rules = alerts?.data ?? [];
|
||||
setRulesExist(rules.length > 0);
|
||||
|
||||
const sortedRules = [...rules].sort((a, b) => {
|
||||
// First, prioritize firing alerts
|
||||
if (a.state === 'firing' && b.state !== 'firing') {
|
||||
return -1;
|
||||
}
|
||||
if (a.state !== 'firing' && b.state === 'firing') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Then sort by updatedAt timestamp
|
||||
return (
|
||||
new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
if (sortedRules.length > 0 && !loadingUserPreferences) {
|
||||
if (rulesExist && !loadingUserPreferences) {
|
||||
onUpdateChecklistDoneItem('SETUP_ALERTS');
|
||||
}
|
||||
|
||||
setSortedAlertRules(sortedRules.slice(0, 5));
|
||||
}, [alerts, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
}, [rulesExist, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
|
||||
const emptyStateCard = (): JSX.Element => (
|
||||
<div className="empty-state-container">
|
||||
@@ -95,7 +63,7 @@ export default function AlertRules({
|
||||
className="periscope-btn secondary"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Homepage: Create alert rule clicked', {});
|
||||
void logEvent('Homepage: Create alert rule clicked', {});
|
||||
}}
|
||||
>
|
||||
Create Alert Rule
|
||||
@@ -106,7 +74,7 @@ export default function AlertRules({
|
||||
type="link"
|
||||
className="learn-more-link"
|
||||
onClick={(): void => {
|
||||
logEvent('Homepage: Learn more clicked', {
|
||||
void logEvent('Homepage: Learn more clicked', {
|
||||
source: 'Alert Rules',
|
||||
});
|
||||
|
||||
@@ -125,26 +93,13 @@ export default function AlertRules({
|
||||
</div>
|
||||
);
|
||||
|
||||
const onEditHandler = (record: RuletypesRuleDTO) => (): void => {
|
||||
logEvent('Homepage: Alert clicked', {
|
||||
const onEditHandler = (record: RuletypesListableRuleDTO) => (): void => {
|
||||
void logEvent('Homepage: Alert clicked', {
|
||||
ruleId: record.id,
|
||||
ruleName: record.alert,
|
||||
ruleState: record.state,
|
||||
});
|
||||
|
||||
const compositeQuery = mapQueryDataFromApi(
|
||||
toCompositeMetricQuery(record.condition.compositeQuery),
|
||||
);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const panelType = record.condition.compositeQuery.panelType;
|
||||
if (panelType) {
|
||||
params.set(QueryParams.panelTypes, panelType);
|
||||
}
|
||||
|
||||
params.set(QueryParams.ruleId, record.id);
|
||||
|
||||
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
@@ -153,11 +108,12 @@ export default function AlertRules({
|
||||
const renderAlertRules = (): JSX.Element => (
|
||||
<div className="alert-rules-container home-data-item-container">
|
||||
<div className="alert-rules-list">
|
||||
{sortedAlertRules.map((rule) => (
|
||||
{alertRules.map((rule) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="alert-rule-item home-data-item"
|
||||
data-testid={`home-alert-rule-${rule.id}`}
|
||||
key={rule.id}
|
||||
onClick={onEditHandler(rule)}
|
||||
onKeyDown={(e): void => {
|
||||
@@ -239,7 +195,7 @@ export default function AlertRules({
|
||||
type="link"
|
||||
className="periscope-btn link learn-more-link"
|
||||
onClick={(): void => {
|
||||
logEvent('Homepage: All alert rules clicked', {});
|
||||
void logEvent('Homepage: All alert rules clicked', {});
|
||||
}}
|
||||
>
|
||||
All Alert Rules <ArrowRight size={12} />
|
||||
|
||||
51
frontend/src/container/Home/AlertRules/useHomeAlertRules.ts
Normal file
51
frontend/src/container/Home/AlertRules/useHomeAlertRules.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useListRulesV3 } from 'api/generated/services/rules';
|
||||
import {
|
||||
type ListRulesV3Params,
|
||||
RuletypesAlertStateDTO,
|
||||
type RuletypesListableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
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 },
|
||||
});
|
||||
const firingRules = firing.data?.data.rules;
|
||||
const latest = useListRulesV3(
|
||||
{ limit: HOME_ALERT_RULES_LIMIT },
|
||||
{
|
||||
query: {
|
||||
cacheTime: 0,
|
||||
enabled:
|
||||
firingRules !== undefined && firingRules.length < HOME_ALERT_RULES_LIMIT,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const rules = useMemo(() => {
|
||||
const firingIds = new Set(firingRules?.map(({ id }) => id));
|
||||
const others = (latest.data?.data.rules ?? []).filter(
|
||||
({ id }) => !firingIds.has(id),
|
||||
);
|
||||
return [...(firingRules ?? []), ...others].slice(0, HOME_ALERT_RULES_LIMIT);
|
||||
}, [firingRules, latest.data]);
|
||||
|
||||
return {
|
||||
rules,
|
||||
isLoading: firing.isLoading || latest.isLoading,
|
||||
isError: firing.isError || latest.isError,
|
||||
};
|
||||
}
|
||||
119
frontend/src/container/Home/__tests__/AlertRules.test.tsx
Normal file
119
frontend/src/container/Home/__tests__/AlertRules.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
RuletypesAlertStateDTO,
|
||||
type RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import AlertRules from '../AlertRules/AlertRules';
|
||||
|
||||
jest.mock('lib/router/navigation', () => ({
|
||||
...jest.requireActual('lib/router/navigation'),
|
||||
navigate: jest.fn(),
|
||||
}));
|
||||
|
||||
const rule = (id: string, state: RuletypesAlertStateDTO): RuletypesRuleDTO => ({
|
||||
...alertRulesFixture[0],
|
||||
id,
|
||||
alert: `${id} rule`,
|
||||
state,
|
||||
});
|
||||
|
||||
const burning = rule('burning', RuletypesAlertStateDTO.firing);
|
||||
const quiet = rule('quiet', RuletypesAlertStateDTO.inactive);
|
||||
const waiting = rule('waiting', RuletypesAlertStateDTO.pending);
|
||||
|
||||
const serveRules = (
|
||||
rules: RuletypesRuleDTO[],
|
||||
rulesForLatest = rules,
|
||||
): string[] => {
|
||||
const requestedStates: string[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
const states = req.url.searchParams.getAll('states').join();
|
||||
requestedStates.push(states);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
listRulesV3Response(
|
||||
states === 'firing' ? rules : rulesForLatest,
|
||||
req.url.searchParams,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return requestedStates;
|
||||
};
|
||||
|
||||
const renderAlertRules = (): void => {
|
||||
render(
|
||||
<AlertRules
|
||||
onUpdateChecklistDoneItem={jest.fn()}
|
||||
loadingUserPreferences={false}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
const renderedRuleIds = (): (string | undefined)[] =>
|
||||
screen.getAllByTestId(/^home-alert-rule-/).map((row) => row.dataset.testid);
|
||||
|
||||
describe('Home alert rules', () => {
|
||||
it('lists firing rules first, then the latest others, and opens the clicked rule', async () => {
|
||||
const requestedStates = serveRules([quiet, burning, waiting]);
|
||||
renderAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('home-alert-rule-waiting'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(renderedRuleIds()).toStrictEqual([
|
||||
'home-alert-rule-burning',
|
||||
'home-alert-rule-quiet',
|
||||
'home-alert-rule-waiting',
|
||||
]);
|
||||
expect(requestedStates).toStrictEqual(['firing', '']);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByTestId('home-alert-rule-burning'));
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
`${ROUTES.ALERT_OVERVIEW}?${QueryParams.ruleId}=burning`,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the latest rules request when 5 rules are firing', async () => {
|
||||
const firing = Array.from({ length: 5 }, (_, index) =>
|
||||
rule(`firing-${index}`, RuletypesAlertStateDTO.firing),
|
||||
);
|
||||
const requestedStates = serveRules([quiet, ...firing]);
|
||||
renderAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('home-alert-rule-firing-4'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(renderedRuleIds()).toStrictEqual(
|
||||
firing.map(({ id }) => `home-alert-rule-${id}`),
|
||||
);
|
||||
expect(requestedStates).toStrictEqual(['firing']);
|
||||
});
|
||||
|
||||
it('shows a rule once when it stops firing between the two requests', async () => {
|
||||
const settled = { ...burning, state: RuletypesAlertStateDTO.inactive };
|
||||
serveRules([burning], [settled, quiet]);
|
||||
renderAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('home-alert-rule-quiet'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(renderedRuleIds()).toStrictEqual([
|
||||
'home-alert-rule-burning',
|
||||
'home-alert-rule-quiet',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,11 @@ 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';
|
||||
@@ -35,18 +33,7 @@ export function useAlertRulesData(
|
||||
const hasLoggedEvent = useRef(false);
|
||||
|
||||
const rulesResponse = useListRulesV3(params, {
|
||||
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,
|
||||
}),
|
||||
},
|
||||
query: { keepPreviousData: true },
|
||||
});
|
||||
|
||||
const rules = useMemo(
|
||||
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
useDeleteDowntimeScheduleByID,
|
||||
useListDowntimeSchedules,
|
||||
} from 'api/generated/services/downtimeschedules';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import type { AlertmanagertypesPlannedMaintenanceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ColumnSelector } from 'components/TanStackTableView';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import dayjs from 'dayjs';
|
||||
import { useListAllRules } from 'hooks/alerts/useListAllRules';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
@@ -55,9 +55,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching: isFetchingAlerts,
|
||||
} = useListRules({
|
||||
query: { cacheTime: 0 },
|
||||
});
|
||||
} = useListAllRules({ cacheTime: 0 });
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const { user } = useAppContext();
|
||||
@@ -73,7 +71,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
const downtimeSchedules = useListDowntimeSchedules();
|
||||
const alertOptions = React.useMemo(
|
||||
() =>
|
||||
alertsData?.data?.map((i) => ({
|
||||
alertsData?.map((i) => ({
|
||||
label: i.alert,
|
||||
value: i.id,
|
||||
})),
|
||||
@@ -181,7 +179,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
};
|
||||
|
||||
const refetchAllSchedules = (): void => {
|
||||
downtimeSchedules.refetch();
|
||||
void downtimeSchedules.refetch();
|
||||
};
|
||||
|
||||
const {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fireEvent, screen } from '@testing-library/react';
|
||||
import type {
|
||||
ListDowntimeSchedules200,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesListableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AlertmanagertypesRepeatOnDTO,
|
||||
@@ -126,19 +127,19 @@ jest.mock('api/generated/services/downtimeschedules', () => ({
|
||||
}));
|
||||
|
||||
let mockRulesState: {
|
||||
data?: { data: Array<{ id: number | string; alert: string }> };
|
||||
data?: Pick<RuletypesListableRuleDTO, 'id' | 'alert'>[];
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
} = {
|
||||
data: { data: [] },
|
||||
data: [],
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
};
|
||||
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
useListRules: (): typeof mockRulesState => mockRulesState,
|
||||
jest.mock('hooks/alerts/useListAllRules', () => ({
|
||||
useListAllRules: (): typeof mockRulesState => mockRulesState,
|
||||
}));
|
||||
|
||||
describe('PlannedDowntime Component', () => {
|
||||
@@ -151,7 +152,7 @@ describe('PlannedDowntime Component', () => {
|
||||
});
|
||||
mockDowntimeQueryResult.data = MOCK_DOWNTIME_RESPONSE;
|
||||
mockRulesState = {
|
||||
data: { data: [] },
|
||||
data: [],
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
@@ -362,7 +363,7 @@ describe('PlannedDowntime Component', () => {
|
||||
|
||||
it('shows "Unknown rule" when scoped rule IDs cannot be resolved (Bug 56)', async () => {
|
||||
mockRulesState = {
|
||||
data: { data: [] },
|
||||
data: [],
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
@@ -387,14 +388,7 @@ describe('PlannedDowntime Component', () => {
|
||||
|
||||
it('shows resolved alert rule name when scoped rule ID exists', async () => {
|
||||
mockRulesState = {
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'rule-42',
|
||||
alert: 'Disk Space High',
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
data: [{ id: 'rule-42', alert: 'Disk Space High' }],
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
|
||||
40
frontend/src/hooks/alerts/__tests__/useListAllRules.test.tsx
Normal file
40
frontend/src/hooks/alerts/__tests__/useListAllRules.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { useListAllRules } from '../useListAllRules';
|
||||
|
||||
const TOTAL = 250;
|
||||
|
||||
const rules = Array.from({ length: TOTAL }, (_, index) => ({
|
||||
...alertRulesFixture[0],
|
||||
id: `rule-${index}`,
|
||||
alert: `Rule ${index}`,
|
||||
}));
|
||||
|
||||
describe('useListAllRules', () => {
|
||||
it('fetches every page past the 200-rule cap', async () => {
|
||||
const offsets: string[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
offsets.push(req.url.searchParams.get('offset') ?? '');
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response(rules, req.url.searchParams)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useListAllRules(), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toHaveLength(TOTAL);
|
||||
expect(result.current.data?.[TOTAL - 1]?.id).toBe(`rule-${TOTAL - 1}`);
|
||||
expect(offsets.sort()).toStrictEqual(['0', '200']);
|
||||
});
|
||||
});
|
||||
40
frontend/src/hooks/alerts/useListAllRules.ts
Normal file
40
frontend/src/hooks/alerts/useListAllRules.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import {
|
||||
getListRulesV3QueryKey,
|
||||
listRulesV3,
|
||||
} from 'api/generated/services/rules';
|
||||
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { range } from 'lodash-es';
|
||||
|
||||
// The list API clamps any larger limit to 200.
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
export const LIST_ALL_RULES_QUERY_KEY = [...getListRulesV3QueryKey(), 'all'];
|
||||
|
||||
function fetchPage(offset: number): ReturnType<typeof listRulesV3> {
|
||||
return listRulesV3({ limit: PAGE_SIZE, offset });
|
||||
}
|
||||
|
||||
// TODO(AshwinBhatkal): temporary. Replace with server-side search plus a lookup of saved rules by ID once
|
||||
// /api/v3/rules can filter by IDs: https://github.com/SigNoz/pulse-pod/issues/715
|
||||
async function fetchAllRules(): Promise<RuletypesListableRuleDTO[]> {
|
||||
const first = await fetchPage(0);
|
||||
const rest = await Promise.all(
|
||||
range(PAGE_SIZE, first.data.total, PAGE_SIZE).map(fetchPage),
|
||||
);
|
||||
|
||||
return [...first.data.rules, ...rest.flatMap((page) => page.data.rules)];
|
||||
}
|
||||
|
||||
export function useListAllRules(
|
||||
options?: Omit<
|
||||
UseQueryOptions<RuletypesListableRuleDTO[], Error>,
|
||||
'queryKey' | 'queryFn'
|
||||
>,
|
||||
): UseQueryResult<RuletypesListableRuleDTO[], Error> {
|
||||
return useQuery<RuletypesListableRuleDTO[], Error>({
|
||||
queryKey: LIST_ALL_RULES_QUERY_KEY,
|
||||
queryFn: fetchAllRules,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { createVariableWidthBarsPathBuilder } from '../variableWidthBars';
|
||||
|
||||
class FakePath2D {
|
||||
rects: number[][] = [];
|
||||
|
||||
rect(x: number, y: number, width: number, height: number): void {
|
||||
this.rects.push([x, y, width, height]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 10 px per unit on both axes; y grows downward from 100. */
|
||||
const fakePlot: Partial<uPlot> = {
|
||||
valToPos: (value: number, scaleKey: string): number =>
|
||||
scaleKey === 'x' ? value * 10 : 100 - value * 10,
|
||||
ctx: {} as CanvasRenderingContext2D,
|
||||
};
|
||||
const plot = fakePlot as uPlot;
|
||||
|
||||
type Fills = Map<CanvasRenderingContext2D['fillStyle'], Path2D>;
|
||||
|
||||
function fillsOf(paths: ReturnType<uPlot.Series.PathBuilder>): Fills {
|
||||
const fill = paths?.fill;
|
||||
if (!(fill instanceof Map)) {
|
||||
throw new Error('expected a fill per style');
|
||||
}
|
||||
return fill;
|
||||
}
|
||||
|
||||
function rectsOf(path: Path2D | undefined): number[][] {
|
||||
if (!(path instanceof FakePath2D)) {
|
||||
throw new Error('expected a FakePath2D');
|
||||
}
|
||||
return path.rects;
|
||||
}
|
||||
|
||||
describe('createVariableWidthBarsPathBuilder', () => {
|
||||
const originalPath2D = globalThis.Path2D;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'Path2D', {
|
||||
value: FakePath2D,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(globalThis, 'Path2D', {
|
||||
value: originalPath2D,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
function build(
|
||||
pattern: CanvasPattern | null = null,
|
||||
): ReturnType<uPlot.Series.PathBuilder> {
|
||||
return createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [
|
||||
{ x0: 0, x1: 1, y0: 0, y1: 2, hatched: false },
|
||||
{ x0: 1, x1: 4, y0: 0, y1: 5, hatched: false },
|
||||
{ x0: 4, x1: 6, y0: 1, y1: 3, hatched: true },
|
||||
],
|
||||
fill: '#ff0000',
|
||||
hatchedFill: '#ff000066',
|
||||
getHatchPattern: () => pattern,
|
||||
gapPx: 2,
|
||||
})(plot, 1, 0, 0);
|
||||
}
|
||||
|
||||
it('draws each bar at its own width, inset by half the gap', () => {
|
||||
const fills = fillsOf(build());
|
||||
|
||||
expect(rectsOf(fills.get('#ff0000'))).toStrictEqual([
|
||||
[1, 80, 8, 20],
|
||||
[11, 50, 28, 50],
|
||||
]);
|
||||
});
|
||||
|
||||
it('draws hatched bars in their own fill, with the pattern on top', () => {
|
||||
const pattern = {} as CanvasPattern;
|
||||
const fills = fillsOf(build(pattern));
|
||||
|
||||
expect([...fills.keys()]).toStrictEqual(['#ff0000', '#ff000066', pattern]);
|
||||
expect(rectsOf(fills.get(pattern))).toStrictEqual([[41, 70, 18, 20]]);
|
||||
});
|
||||
it('reads a fill callback on every draw', () => {
|
||||
let fill = '#ff0000';
|
||||
const builder = createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false }],
|
||||
fill: () => fill,
|
||||
hatchedFill: '#00ff00',
|
||||
getHatchPattern: () => null,
|
||||
gapPx: 0,
|
||||
});
|
||||
|
||||
const first = fillsOf(builder(plot, 1, 0, 0));
|
||||
fill = '#0000ff';
|
||||
const second = fillsOf(builder(plot, 1, 0, 0));
|
||||
|
||||
expect([...first.keys()]).toStrictEqual(['#ff0000']);
|
||||
expect([...second.keys()]).toStrictEqual(['#0000ff']);
|
||||
});
|
||||
|
||||
it('keeps solid bars when the hatched fill is the same colour', () => {
|
||||
const fills = fillsOf(
|
||||
createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [
|
||||
{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false },
|
||||
{ x0: 1, x1: 2, y0: 0, y1: 1, hatched: true },
|
||||
],
|
||||
fill: 'red',
|
||||
hatchedFill: 'red',
|
||||
getHatchPattern: () => null,
|
||||
gapPx: 0,
|
||||
})(plot, 1, 0, 0),
|
||||
);
|
||||
|
||||
expect(rectsOf(fills.get('red'))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('hatches only overflow bars when both fills are the same colour', () => {
|
||||
const pattern = {} as CanvasPattern;
|
||||
const fills = fillsOf(
|
||||
createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [
|
||||
{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false },
|
||||
{ x0: 1, x1: 2, y0: 0, y1: 1, hatched: true },
|
||||
],
|
||||
fill: 'red',
|
||||
hatchedFill: 'red',
|
||||
getHatchPattern: () => pattern,
|
||||
gapPx: 0,
|
||||
})(plot, 1, 0, 0),
|
||||
);
|
||||
|
||||
expect(rectsOf(fills.get(pattern))).toStrictEqual([[10, 90, 10, 10]]);
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
/** A bar in scale space. Unlike uPlot's own bars, each one carries its own width. */
|
||||
export interface VariableWidthBar {
|
||||
x0: number;
|
||||
x1: number;
|
||||
y0: number;
|
||||
y1: number;
|
||||
/** Drawn in `hatchedFill` with the hatch pattern on top. */
|
||||
hatched: boolean;
|
||||
}
|
||||
|
||||
/** A fill, or one read on every draw (e.g. to dim a series on hover). */
|
||||
type BarFill = string | (() => string);
|
||||
|
||||
/** Bars narrower than this, in CSS pixels, keep no gap between them. */
|
||||
const MIN_WIDTH_FOR_GAP_PX = 3;
|
||||
|
||||
/**
|
||||
* Draws `[x0, x1] × [y0, y1]` rects on the `x` and `y` scales. uPlot's bar builder
|
||||
* assumes one width for every bar, which a bucket axis with uneven bounds breaks.
|
||||
*
|
||||
* `getBars` runs on every redraw, so a caller can derive the bars from the plot's
|
||||
* current state (e.g. which series are shown).
|
||||
*/
|
||||
export function createVariableWidthBarsPathBuilder({
|
||||
getBars,
|
||||
fill,
|
||||
hatchedFill,
|
||||
getHatchPattern,
|
||||
gapPx,
|
||||
}: {
|
||||
getBars: (u: uPlot, seriesIdx: number) => VariableWidthBar[];
|
||||
fill: BarFill;
|
||||
hatchedFill: BarFill;
|
||||
getHatchPattern: (ctx: CanvasRenderingContext2D) => CanvasPattern | null;
|
||||
/** Space between neighbouring bars, in CSS pixels. */
|
||||
gapPx: number;
|
||||
}): uPlot.Series.PathBuilder {
|
||||
return (u, seriesIdx): uPlot.Series.Paths => {
|
||||
const solidFill = resolveFill(fill);
|
||||
const overflowFill = resolveFill(hatchedFill);
|
||||
const solidPath = new Path2D();
|
||||
// The hatch overlays only overflow bars, so they keep a path of their own even
|
||||
// when one colour fill covers both (a Map keyed by fill holds one path a fill).
|
||||
const hatchedPath = new Path2D();
|
||||
const isSharedFill = overflowFill === solidFill;
|
||||
const inset = (gapPx * uPlot.pxRatio) / 2;
|
||||
const minWidthForGap = MIN_WIDTH_FOR_GAP_PX * uPlot.pxRatio;
|
||||
let hasHatched = false;
|
||||
|
||||
getBars(u, seriesIdx).forEach((bar) => {
|
||||
const left = u.valToPos(bar.x0, 'x', true);
|
||||
const right = u.valToPos(bar.x1, 'x', true);
|
||||
const top = u.valToPos(bar.y1, 'y', true);
|
||||
const bottom = u.valToPos(bar.y0, 'y', true);
|
||||
const width = right - left;
|
||||
const gap = width > minWidthForGap ? inset : 0;
|
||||
const rect = [left + gap, top, width - 2 * gap, bottom - top] as const;
|
||||
if (!bar.hatched || isSharedFill) {
|
||||
solidPath.rect(...rect);
|
||||
}
|
||||
if (bar.hatched) {
|
||||
hatchedPath.rect(...rect);
|
||||
hasHatched = true;
|
||||
}
|
||||
});
|
||||
|
||||
const fills = new Map<CanvasRenderingContext2D['fillStyle'], Path2D>([
|
||||
[solidFill, solidPath],
|
||||
]);
|
||||
if (hasHatched) {
|
||||
if (!isSharedFill) {
|
||||
fills.set(overflowFill, hatchedPath);
|
||||
}
|
||||
const pattern = getHatchPattern(u.ctx);
|
||||
if (pattern) {
|
||||
fills.set(pattern, hatchedPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { stroke: null, fill: fills, clip: null, band: null, flags: 0 };
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFill(fill: BarFill): string {
|
||||
return typeof fill === 'function' ? fill() : fill;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.notice {
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import {
|
||||
LegendPosition,
|
||||
type TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import { resolveBoundaryPrecision } from 'lib/visualization/charts/Heatmap/utils';
|
||||
import { DistributionChartProps } from 'lib/visualization/charts/types';
|
||||
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
|
||||
import type { LegendLayout } from 'lib/visualization/layout/ChartLayout/ChartLayout';
|
||||
|
||||
import { resolveOtherColor, resolveSeriesColor } from './colors';
|
||||
import DistributionTooltip from './components/DistributionTooltip/DistributionTooltip';
|
||||
import { buildDistributionConfig } from './config';
|
||||
import {
|
||||
clampDistributionXAxis,
|
||||
cropDistributionXAxis,
|
||||
resolveDistributionBars,
|
||||
resolveDistributionXAxis,
|
||||
sumVisibleCounts,
|
||||
} from './layout';
|
||||
import { computeDistributionPercentiles } from './percentiles';
|
||||
import { groupIntoOther, resolveMaxDrawnGroups } from './topGroups';
|
||||
import { DistributionYScale } from './types';
|
||||
import { useDistributionBarClick } from './useDistributionBarClick';
|
||||
|
||||
import styles from './Distribution.module.scss';
|
||||
import { createBoundFormatter, prepareDistributionChartData } from './utils';
|
||||
|
||||
/** Rough share of the panel width the plot gets, for fitting side-by-side bars. */
|
||||
const PLOT_SHARE = 0.9;
|
||||
const PLOT_SHARE_WITH_RIGHT_LEGEND = 0.65;
|
||||
/** Vertical space the Other notice takes out of the container. */
|
||||
const NOTICE_HEIGHT = 20;
|
||||
const NO_PERCENTILES: readonly number[] = Object.freeze([]);
|
||||
|
||||
export default function Distribution({
|
||||
id,
|
||||
buckets,
|
||||
series,
|
||||
width,
|
||||
height,
|
||||
isDarkMode,
|
||||
xMin,
|
||||
xMax,
|
||||
yScale = DistributionYScale.Linear,
|
||||
stack = StackMode.Normal,
|
||||
xAxisUnit,
|
||||
decimalPrecision,
|
||||
customColors,
|
||||
percentiles = NO_PERCENTILES,
|
||||
onBarClick,
|
||||
showLegend = true,
|
||||
legendPosition = LegendPosition.BOTTOM,
|
||||
showTooltip = true,
|
||||
canPinTooltip = false,
|
||||
renderTooltipFooter,
|
||||
tooltipPortalRoot,
|
||||
layoutChildren,
|
||||
'data-testid': testId,
|
||||
}: DistributionChartProps): JSX.Element {
|
||||
const labels = useMemo(() => series.map((entry) => entry.label), [series]);
|
||||
const counts = useMemo(() => series.map((entry) => entry.counts), [series]);
|
||||
|
||||
const { visibleKeys, focusedSeriesIndex, onLegendAction } =
|
||||
useLegendVisibility({ keys: labels, indexOffset: 1, id });
|
||||
|
||||
const visible = useMemo(
|
||||
() => labels.map((label) => visibleKeys.includes(label)),
|
||||
[labels, visibleKeys],
|
||||
);
|
||||
|
||||
const colors = useMemo(
|
||||
() =>
|
||||
labels.map((label) => resolveSeriesColor(label, isDarkMode, customColors)),
|
||||
[labels, isDarkMode, customColors],
|
||||
);
|
||||
|
||||
const { xAxis, totals } = useMemo(() => {
|
||||
const full = resolveDistributionXAxis(buckets);
|
||||
const visibleTotals = sumVisibleCounts(counts, visible, full.rows.length);
|
||||
// An explicit max may reach past the last occupied bucket; only auto-crop without one.
|
||||
const fitted =
|
||||
xMax === undefined ? cropDistributionXAxis(full, visibleTotals) : full;
|
||||
return {
|
||||
xAxis: clampDistributionXAxis(fitted, xMin, xMax),
|
||||
totals: visibleTotals,
|
||||
};
|
||||
}, [buckets, xMin, xMax, counts, visible]);
|
||||
|
||||
// Over the visible groups combined, so a legend toggle moves them without a refetch.
|
||||
const markers = useMemo(
|
||||
() => computeDistributionPercentiles({ xAxis, totals, percentiles }),
|
||||
[xAxis, totals, percentiles],
|
||||
);
|
||||
|
||||
const isLogY =
|
||||
yScale === DistributionYScale.Log && stack !== StackMode.Percent;
|
||||
|
||||
// The legend lists every group; the plot draws the largest and sums the rest into
|
||||
// Other, so a toggle re-ranks without a refetch.
|
||||
const drawn = useMemo(
|
||||
() =>
|
||||
groupIntoOther({
|
||||
labels,
|
||||
counts,
|
||||
colors,
|
||||
visible,
|
||||
maxGroups: resolveMaxDrawnGroups({
|
||||
stack,
|
||||
xAxis,
|
||||
plotWidth:
|
||||
width *
|
||||
(legendPosition === LegendPosition.RIGHT
|
||||
? PLOT_SHARE_WITH_RIGHT_LEGEND
|
||||
: PLOT_SHARE),
|
||||
}),
|
||||
otherColor: resolveOtherColor(isDarkMode),
|
||||
}),
|
||||
[
|
||||
labels,
|
||||
counts,
|
||||
colors,
|
||||
visible,
|
||||
stack,
|
||||
xAxis,
|
||||
width,
|
||||
legendPosition,
|
||||
isDarkMode,
|
||||
],
|
||||
);
|
||||
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: drawn.counts,
|
||||
stack,
|
||||
isLogY,
|
||||
}),
|
||||
[xAxis, drawn, stack, isLogY],
|
||||
);
|
||||
|
||||
const boundaryPrecision = useMemo(
|
||||
() =>
|
||||
resolveBoundaryPrecision({
|
||||
yAxis: xAxis,
|
||||
yAxisUnit: xAxisUnit,
|
||||
decimalPrecision,
|
||||
}),
|
||||
[xAxis, xAxisUnit, decimalPrecision],
|
||||
);
|
||||
|
||||
const formatBound = useMemo(
|
||||
() => createBoundFormatter(xAxisUnit, boundaryPrecision),
|
||||
[xAxisUnit, boundaryPrecision],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildDistributionConfig({
|
||||
id,
|
||||
xAxis,
|
||||
layout,
|
||||
labels: drawn.labels,
|
||||
colors: drawn.colors,
|
||||
stack,
|
||||
isLogY,
|
||||
isDarkMode,
|
||||
markers,
|
||||
xAxisUnit,
|
||||
decimalPrecision: boundaryPrecision,
|
||||
}),
|
||||
[
|
||||
id,
|
||||
xAxis,
|
||||
layout,
|
||||
drawn,
|
||||
stack,
|
||||
isLogY,
|
||||
isDarkMode,
|
||||
markers,
|
||||
xAxisUnit,
|
||||
boundaryPrecision,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => (
|
||||
<DistributionTooltip
|
||||
uPlotInstance={args.uPlotInstance}
|
||||
isPinned={args.isPinned}
|
||||
dismiss={args.dismiss}
|
||||
xAxis={xAxis}
|
||||
layout={layout}
|
||||
labels={drawn.labels}
|
||||
colors={drawn.colors}
|
||||
counts={drawn.counts}
|
||||
formatBound={formatBound}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
/>
|
||||
),
|
||||
[xAxis, layout, drawn, formatBound, renderTooltipFooter],
|
||||
);
|
||||
|
||||
const handleClick = useDistributionBarClick({
|
||||
xAxis,
|
||||
layout,
|
||||
drawn,
|
||||
series,
|
||||
onBarClick,
|
||||
});
|
||||
|
||||
const data = useMemo(
|
||||
() => prepareDistributionChartData(xAxis, drawn.counts),
|
||||
[xAxis, drawn],
|
||||
);
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
labels.map((label, index) => ({
|
||||
seriesIndex: index + 1,
|
||||
label,
|
||||
color: colors[index],
|
||||
show: visible[index],
|
||||
})),
|
||||
[labels, colors, visible],
|
||||
);
|
||||
|
||||
const groupLegend = useCallback(
|
||||
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => (
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={legendPosition}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showSearch}
|
||||
itemLabel="groups"
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
),
|
||||
[legendItems, legendPosition, focusedSeriesIndex, onLegendAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
config={config}
|
||||
data={data}
|
||||
width={width}
|
||||
height={
|
||||
drawn.otherGroupCount > 0 ? Math.max(0, height - NOTICE_HEIGHT) : height
|
||||
}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
showLegend={showLegend}
|
||||
customLegend={groupLegend}
|
||||
legendLabels={labels}
|
||||
showTooltip={showTooltip}
|
||||
canPinTooltip={canPinTooltip}
|
||||
decimalPrecision={decimalPrecision}
|
||||
customTooltip={renderTooltip}
|
||||
onClick={handleClick}
|
||||
contentFooter={
|
||||
drawn.otherGroupCount > 0 && (
|
||||
<div className={styles.notice} data-testid="distribution-other-notice">
|
||||
Showing top {drawn.labels.length - 1} of {drawn.visibleGroupCount} groups;
|
||||
the rest are summed into Other.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
tooltipPortalRoot={tooltipPortalRoot}
|
||||
layoutChildren={layoutChildren}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import type React from 'react';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import Distribution from '../Distribution';
|
||||
import type { DistributionSeries } from '../types';
|
||||
|
||||
// The shared Legend virtualises its items; render them all so they are queryable.
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<div>
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex}>{itemContent(index, item)}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const BUCKETS = [5, 10, 100, 1000];
|
||||
|
||||
const SERIES: DistributionSeries[] = [
|
||||
{ label: 'service.name=cart', counts: [3, 40, 12, 1, 0] },
|
||||
{ label: 'service.name=checkout', counts: [1, 10, 30, 6, 2] },
|
||||
];
|
||||
|
||||
function renderDistribution(
|
||||
props: Partial<React.ComponentProps<typeof Distribution>> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<Distribution
|
||||
id="panel-1"
|
||||
buckets={BUCKETS}
|
||||
series={SERIES}
|
||||
width={800}
|
||||
height={400}
|
||||
isDarkMode
|
||||
data-testid="distribution"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Distribution', () => {
|
||||
// Legend items count from 1, as uPlot's data series do.
|
||||
const CART = 1;
|
||||
const CHECKOUT = 2;
|
||||
|
||||
// The selection persists under the panel id, which every case here shares.
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
function legendItem(seriesIndex: number): HTMLElement {
|
||||
return screen.getByTestId(`legend-item-${seriesIndex}`);
|
||||
}
|
||||
|
||||
it('renders the plot container', () => {
|
||||
renderDistribution();
|
||||
|
||||
expect(screen.getByTestId('distribution')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists every group in the legend, all shown', () => {
|
||||
renderDistribution();
|
||||
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('isolates a group when its row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderDistribution();
|
||||
|
||||
await user.click(legendItem(CART));
|
||||
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('hides only that group when its marker is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderDistribution();
|
||||
|
||||
await user.click(screen.getByTestId(`legend-marker-${CHECKOUT}`));
|
||||
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
it('sums groups past the stacked cap into Other, with a notice', () => {
|
||||
renderDistribution({
|
||||
series: Array.from({ length: 12 }, (_, index) => ({
|
||||
label: `group-${index}`,
|
||||
counts: [index, 1, 1, 0, 0],
|
||||
})),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('distribution-other-notice')).toHaveTextContent(
|
||||
'Showing top 9 of 12 groups; the rest are summed into Other.',
|
||||
);
|
||||
// The legend still lists every group, so any can be isolated.
|
||||
expect(legendItem(1)).toHaveAttribute('aria-label', 'group-0');
|
||||
});
|
||||
|
||||
it('shows no notice when every group fits', () => {
|
||||
renderDistribution();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('distribution-other-notice'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { resolveDistributionBars, resolveDistributionXAxis } from '../layout';
|
||||
import { buildDistributionConfig } from '../config';
|
||||
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4]. Only (10, 20] is occupied.
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40]);
|
||||
const counts = [
|
||||
[0, 4, 0, 0],
|
||||
[0, 6, 0, 0],
|
||||
];
|
||||
|
||||
function setCursorHook(drawnCounts = counts): (u: uPlot) => void {
|
||||
const config = buildDistributionConfig({
|
||||
id: 'panel-1',
|
||||
xAxis,
|
||||
layout: resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: drawnCounts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
}),
|
||||
labels: drawnCounts.map((_, index) => `group-${index}`),
|
||||
colors: drawnCounts.map(() => '#ff0000'),
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
isDarkMode: true,
|
||||
markers: [],
|
||||
}).getConfig();
|
||||
const [hook] = config.hooks?.setCursor ?? [];
|
||||
return hook as (u: uPlot) => void;
|
||||
}
|
||||
|
||||
/** Pixels are values, so the cursor sits at `(left, top)` in scale space. */
|
||||
function plotAt(
|
||||
left: number,
|
||||
top: number,
|
||||
): { u: uPlot; setSeries: jest.Mock; redraw: jest.Mock } {
|
||||
const setSeries = jest.fn();
|
||||
const redraw = jest.fn();
|
||||
const plot: Partial<uPlot> = {
|
||||
cursor: { left, top },
|
||||
posToVal: (position: number) => position,
|
||||
setSeries,
|
||||
redraw,
|
||||
};
|
||||
return { u: plot as uPlot, setSeries, redraw };
|
||||
}
|
||||
|
||||
describe('buildDistributionConfig cursor', () => {
|
||||
it('turns off drag-to-zoom', () => {
|
||||
const config = buildDistributionConfig({
|
||||
id: 'panel-1',
|
||||
xAxis,
|
||||
layout: resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
}),
|
||||
labels: ['group-0', 'group-1'],
|
||||
colors: ['#ff0000', '#ff0000'],
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
isDarkMode: true,
|
||||
markers: [],
|
||||
}).getConfig();
|
||||
|
||||
expect(config.cursor?.drag).toMatchObject({ x: false, y: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDistributionConfig setCursor', () => {
|
||||
// TooltipPlugin only shows a tooltip for a focused series.
|
||||
it('focuses the group under the cursor, 1-based as uPlot counts series', () => {
|
||||
const { u, setSeries, redraw } = plotAt(1.5, 8);
|
||||
|
||||
setCursorHook()(u);
|
||||
|
||||
expect(setSeries).toHaveBeenCalledWith(2, { focus: true });
|
||||
expect(redraw).toHaveBeenCalledWith(true, false);
|
||||
});
|
||||
|
||||
it('focuses the nearest group above the stack, dimming the rest', () => {
|
||||
const { u, setSeries, redraw } = plotAt(1.5, 50);
|
||||
|
||||
setCursorHook()(u);
|
||||
|
||||
expect(setSeries).toHaveBeenCalledWith(2, { focus: true });
|
||||
expect(redraw).toHaveBeenCalledWith(true, false);
|
||||
});
|
||||
|
||||
it('keeps a tooltip over an empty bucket without dimming any group', () => {
|
||||
const hook = setCursorHook();
|
||||
const { u, setSeries, redraw } = plotAt(1.5, 8);
|
||||
hook(u);
|
||||
redraw.mockClear();
|
||||
|
||||
u.cursor.left = 2.5;
|
||||
u.cursor.top = 1;
|
||||
hook(u);
|
||||
|
||||
expect(setSeries).toHaveBeenLastCalledWith(1, { focus: true });
|
||||
expect(redraw).toHaveBeenCalledWith(true, false);
|
||||
});
|
||||
|
||||
it('focuses nothing off the plot', () => {
|
||||
const hook = setCursorHook();
|
||||
const { u, setSeries } = plotAt(1.5, 8);
|
||||
hook(u);
|
||||
|
||||
u.cursor.left = -1;
|
||||
hook(u);
|
||||
|
||||
expect(setSeries).toHaveBeenLastCalledWith(null, { focus: true });
|
||||
});
|
||||
|
||||
it('focuses nothing over an empty bucket when every group is hidden', () => {
|
||||
const { u, setSeries } = plotAt(2.5, 1);
|
||||
|
||||
setCursorHook([])(u);
|
||||
|
||||
expect(setSeries).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import {
|
||||
layoutMarkerLabels,
|
||||
resolveHoveredMarker,
|
||||
} from '../drawPercentileMarkers';
|
||||
|
||||
describe('resolveHoveredMarker', () => {
|
||||
const areas = [
|
||||
{
|
||||
x: 100,
|
||||
badges: [
|
||||
{ left: 92, right: 108, top: 0, bottom: 16 },
|
||||
{ left: 92, right: 108, top: 184, bottom: 200 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 120,
|
||||
badges: [{ left: 112, right: 128, top: 18, bottom: 34 }],
|
||||
},
|
||||
];
|
||||
|
||||
it('picks the line nearest x within the tolerance', () => {
|
||||
expect(resolveHoveredMarker(areas, 103, 100, 4)).toBe(0);
|
||||
expect(resolveHoveredMarker(areas, 117, 100, 4)).toBe(1);
|
||||
expect(resolveHoveredMarker(areas, 110, 100, 4)).toBeNull();
|
||||
});
|
||||
|
||||
it('picks the marker whose badge is under the cursor', () => {
|
||||
expect(resolveHoveredMarker(areas, 107, 190, 4)).toBe(0);
|
||||
expect(resolveHoveredMarker(areas, 126, 20, 4)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layoutMarkerLabels', () => {
|
||||
it('keeps labels that fit on the first row', () => {
|
||||
expect(
|
||||
layoutMarkerLabels(
|
||||
[
|
||||
{ x: 0, width: 20 },
|
||||
{ x: 40, width: 20 },
|
||||
],
|
||||
4,
|
||||
),
|
||||
).toStrictEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('drops a label that would overlap to the next free row', () => {
|
||||
expect(
|
||||
layoutMarkerLabels(
|
||||
[
|
||||
{ x: 30, width: 20 },
|
||||
{ x: 0, width: 20 },
|
||||
{ x: 10, width: 20 },
|
||||
],
|
||||
4,
|
||||
),
|
||||
).toStrictEqual([0, 0, 1]);
|
||||
});
|
||||
});
|
||||
@@ -1,286 +0,0 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import {
|
||||
clampDistributionXAxis,
|
||||
cropDistributionXAxis,
|
||||
LOG_Y_BASE,
|
||||
resolveDistributionBars,
|
||||
resolveDistributionXAxis,
|
||||
resolveNearestSeries,
|
||||
sumVisibleCounts,
|
||||
thinLabelledSplits,
|
||||
} from '../layout';
|
||||
|
||||
function widths(edges: number[]): number[] {
|
||||
return edges.slice(1).map((edge, index) => edge - edges[index]);
|
||||
}
|
||||
|
||||
describe('resolveDistributionXAxis', () => {
|
||||
it('lays N bounds out as N + 1 buckets, overflow last', () => {
|
||||
const axis = resolveDistributionXAxis([10, 20, 30]);
|
||||
|
||||
expect(axis.rows).toHaveLength(4);
|
||||
expect(axis.rows[0].isUnderflow).toBe(true);
|
||||
expect(axis.rows[3]).toMatchObject({ lower: 30, isOverflow: true });
|
||||
expect(axis.edges).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('gives every bucket the same width, ticks on the bounds', () => {
|
||||
const axis = resolveDistributionXAxis([0, 5, 10, 25, 1000]);
|
||||
|
||||
expect(widths(axis.edges)).toStrictEqual([1, 1, 1, 1, 1, 1]);
|
||||
expect(axis.splits).toStrictEqual([1, 2, 3, 4, 5]);
|
||||
expect(axis.overflowSplit).toBe(6);
|
||||
expect(axis.toBucketValue(3.5)).toBe(17.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cropDistributionXAxis', () => {
|
||||
it('stops one bucket past the last occupied one', () => {
|
||||
const axis = resolveDistributionXAxis([1, 2, 3, 4]);
|
||||
|
||||
const cropped = cropDistributionXAxis(axis, [1, 5, 0, 0, 0]);
|
||||
|
||||
expect(cropped.max).toBe(3);
|
||||
expect(cropped.overflowSplit).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampDistributionXAxis', () => {
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], (40, 80], > 80 in slots [0, 1] … [4, 5].
|
||||
const axis = resolveDistributionXAxis([10, 20, 40, 80]);
|
||||
|
||||
it('keeps every bucket without a range', () => {
|
||||
expect(clampDistributionXAxis(axis)).toStrictEqual(axis);
|
||||
});
|
||||
|
||||
it('hides buckets wholly outside the range, keeping straddling ones whole', () => {
|
||||
const clamped = clampDistributionXAxis(axis, 20, 50);
|
||||
|
||||
expect([clamped.min, clamped.max]).toStrictEqual([2, 4]);
|
||||
expect(clamped.overflowSplit).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the overflow tick while the overflow bucket shows', () => {
|
||||
const clamped = clampDistributionXAxis(axis, 15);
|
||||
|
||||
expect([clamped.min, clamped.max]).toStrictEqual([1, 5]);
|
||||
expect(clamped.overflowSplit).toBe(5);
|
||||
});
|
||||
|
||||
it('keeps one bucket when the range misses them all', () => {
|
||||
const clamped = clampDistributionXAxis(axis, 1000);
|
||||
|
||||
expect([clamped.min, clamped.max]).toStrictEqual([4, 5]);
|
||||
});
|
||||
|
||||
it('narrows an already cropped axis, never widening it', () => {
|
||||
const cropped = cropDistributionXAxis(axis, [1, 5, 0, 0, 0]);
|
||||
|
||||
expect(clampDistributionXAxis(cropped, 15).max).toBe(cropped.max);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sumVisibleCounts', () => {
|
||||
it('adds only the visible series', () => {
|
||||
expect(
|
||||
sumVisibleCounts(
|
||||
[
|
||||
[1, 2],
|
||||
[10, 20],
|
||||
[100, 200],
|
||||
],
|
||||
[true, false, true],
|
||||
2,
|
||||
),
|
||||
).toStrictEqual([101, 202]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDistributionBars', () => {
|
||||
const xAxis = resolveDistributionXAxis([1, 2]);
|
||||
const counts = [
|
||||
[4, 6, 0],
|
||||
[1, 3, 2],
|
||||
];
|
||||
|
||||
it('sizes the count axis by the buckets left in the window', () => {
|
||||
// Buckets ≤ 1, (1, 2], > 2; a max of 1 keeps only the first.
|
||||
const { yMax } = resolveDistributionBars({
|
||||
xAxis: clampDistributionXAxis(xAxis, undefined, 1),
|
||||
counts: [[4, 1000, 0]],
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(yMax).toBe(4);
|
||||
});
|
||||
|
||||
it('stacks series in each bucket under normal', () => {
|
||||
const { rects, yMax } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[0].map(({ y0, y1 }) => [y0, y1])).toStrictEqual([
|
||||
[0, 4],
|
||||
[0, 6],
|
||||
]);
|
||||
expect(rects[1].map(({ y0, y1 }) => [y0, y1])).toStrictEqual([
|
||||
[4, 5],
|
||||
[6, 9],
|
||||
[0, 2],
|
||||
]);
|
||||
expect(yMax).toBe(9);
|
||||
});
|
||||
|
||||
it('marks the overflow bucket hatched', () => {
|
||||
const { rects } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[1].map(({ hatched }) => hatched)).toStrictEqual([
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('stacks shares of each bucket under percent', () => {
|
||||
const { rects, yMax } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Percent,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[0][0]).toMatchObject({ y0: 0, y1: 80 });
|
||||
expect(rects[1][0]).toMatchObject({ y0: 80, y1: 100 });
|
||||
expect(yMax).toBe(100);
|
||||
});
|
||||
|
||||
it('splits the bucket width between series under none', () => {
|
||||
const { rects, yMax } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.None,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[0][0]).toMatchObject({ x0: 0, x1: 0.5, y0: 0, y1: 4 });
|
||||
expect(rects[1][0]).toMatchObject({ x0: 0.5, x1: 1, y0: 0, y1: 1 });
|
||||
expect(yMax).toBe(6);
|
||||
});
|
||||
|
||||
it('starts bars at the log base and skips zero counts', () => {
|
||||
const { rects } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: true,
|
||||
});
|
||||
|
||||
expect(rects[0].map(({ y0 }) => y0)).toStrictEqual([LOG_Y_BASE, LOG_Y_BASE]);
|
||||
expect(rects[0].map(({ bucketIndex }) => bucketIndex)).toStrictEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('skips bars whose top stays under the log base, never inverting one', () => {
|
||||
const fractional = [
|
||||
[0.5, 3, 0],
|
||||
[0.25, 0.5, 2],
|
||||
];
|
||||
const stacked = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: fractional,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: true,
|
||||
});
|
||||
const sideBySide = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: fractional,
|
||||
stack: StackMode.None,
|
||||
isLogY: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
stacked.rects.map((rects) => rects.map(({ y0, y1 }) => [y0, y1])),
|
||||
).toStrictEqual([
|
||||
[[LOG_Y_BASE, 3]],
|
||||
[
|
||||
[3, 3.5],
|
||||
[LOG_Y_BASE, 2],
|
||||
],
|
||||
]);
|
||||
expect(
|
||||
sideBySide.rects.map((rects) => rects.map(({ bucketIndex }) => bucketIndex)),
|
||||
).toStrictEqual([[1], [2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('thinLabelledSplits', () => {
|
||||
it('keeps labels that clear each other by the gap', () => {
|
||||
expect(
|
||||
thinLabelledSplits({
|
||||
positions: [0, 50, 100],
|
||||
widths: [20, 20, 20],
|
||||
gap: 10,
|
||||
}),
|
||||
).toStrictEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('drops a wide label that would touch its neighbour, keeping the rightmost', () => {
|
||||
// 16.67 mins-wide labels 60 px apart: each needs 70 px of centre distance.
|
||||
expect(
|
||||
thinLabelledSplits({
|
||||
positions: [0, 60, 120, 180],
|
||||
widths: [60, 60, 60, 60],
|
||||
gap: 10,
|
||||
}),
|
||||
).toStrictEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveNearestSeries', () => {
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40]);
|
||||
const counts = [
|
||||
[0, 4, 0, 0],
|
||||
[0, 6, 0, 0],
|
||||
];
|
||||
const stacked = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
it('names the group whose bar is under the point', () => {
|
||||
expect(resolveNearestSeries(stacked, 1, 1.5, 2)).toBe(0);
|
||||
expect(resolveNearestSeries(stacked, 1, 1.5, 8)).toBe(1);
|
||||
});
|
||||
|
||||
it('takes the vertically nearest group off the bars, as a bar chart does', () => {
|
||||
expect(resolveNearestSeries(stacked, 1, 1.5, 50)).toBe(1);
|
||||
});
|
||||
|
||||
it('is null over an empty bucket', () => {
|
||||
expect(resolveNearestSeries(stacked, 2, 2.5, 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('picks the sub-bar under the cursor side by side', () => {
|
||||
const sideBySide = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.None,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(resolveNearestSeries(sideBySide, 1, 1.25, 50)).toBe(0);
|
||||
expect(resolveNearestSeries(sideBySide, 1, 1.75, 1)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import { resolveDistributionXAxis } from '../layout';
|
||||
import {
|
||||
computeDistributionPercentiles,
|
||||
formatPercentileBadge,
|
||||
formatPercentileName,
|
||||
formatPercentileValue,
|
||||
groupPercentileMarkers,
|
||||
} from '../percentiles';
|
||||
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40]);
|
||||
|
||||
describe('computeDistributionPercentiles', () => {
|
||||
it('interpolates inside the bucket holding the rank, as histogram_quantile does', () => {
|
||||
const markers = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [10, 20, 10, 0],
|
||||
percentiles: [50, 90],
|
||||
});
|
||||
|
||||
expect(markers.map(({ value }) => value)).toStrictEqual([15, 32]);
|
||||
expect(markers.every(({ isOverflow }) => !isOverflow)).toBe(true);
|
||||
});
|
||||
|
||||
it('places the marker the same fraction across the bucket on the axis', () => {
|
||||
const [p50] = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [10, 20, 10, 0],
|
||||
percentiles: [50],
|
||||
});
|
||||
|
||||
expect(p50.axisValue).toBe(1.5);
|
||||
});
|
||||
|
||||
it('starts the lowest bucket at zero', () => {
|
||||
const [p10] = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [10, 20, 10, 0],
|
||||
percentiles: [10],
|
||||
});
|
||||
|
||||
expect(p10.value).toBe(4);
|
||||
});
|
||||
|
||||
it('reports a percentile in overflow as above the last bound, at its left edge', () => {
|
||||
const [p99] = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [0, 0, 10, 10],
|
||||
percentiles: [99],
|
||||
});
|
||||
|
||||
expect(p99).toMatchObject({ value: 40, axisValue: 3, isOverflow: true });
|
||||
});
|
||||
|
||||
it('marks nothing when every count is zero', () => {
|
||||
expect(
|
||||
computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [0, 0, 0, 0],
|
||||
percentiles: [50],
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPercentileName', () => {
|
||||
it('keeps fractional percentile names short', () => {
|
||||
expect(formatPercentileName(99.9)).toBe('p99.9');
|
||||
expect(formatPercentileName(50)).toBe('p50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('percentile marker groups', () => {
|
||||
const formatBound = (value: number): string => `${value} ms`;
|
||||
const p50 = { percentile: 50, value: 15, axisValue: 1.5, isOverflow: false };
|
||||
const p99 = { percentile: 99, value: 40, axisValue: 3, isOverflow: true };
|
||||
const p90 = { percentile: 90, value: 40, axisValue: 3, isOverflow: true };
|
||||
|
||||
it('groups markers on the same spot, lowest percentile first', () => {
|
||||
expect(groupPercentileMarkers([p50, p99, p90])).toStrictEqual([
|
||||
[p50],
|
||||
[p90, p99],
|
||||
]);
|
||||
});
|
||||
|
||||
it('names a group by its lowest percentile', () => {
|
||||
expect(formatPercentileBadge([p50])).toBe('p50');
|
||||
expect(formatPercentileBadge([p90, p99])).toBe('p90+');
|
||||
});
|
||||
|
||||
it('shows the estimate, or the overflow bound once for the group', () => {
|
||||
expect(formatPercentileValue([p50], formatBound)).toBe('p50 ≈ 15 ms');
|
||||
expect(formatPercentileValue([p90, p99], formatBound)).toBe(
|
||||
'p90, p99 > 40 ms',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { resolveDistributionXAxis } from '../layout';
|
||||
import {
|
||||
groupIntoOther,
|
||||
resolveClickedGroup,
|
||||
MAX_STACKED_GROUPS,
|
||||
OTHER_GROUP_LABEL,
|
||||
resolveMaxDrawnGroups,
|
||||
} from '../topGroups';
|
||||
|
||||
describe('resolveMaxDrawnGroups', () => {
|
||||
// Four equal buckets plus underflow and overflow: six slots of one unit each.
|
||||
const xAxis = resolveDistributionXAxis([1, 2, 3, 4, 5]);
|
||||
|
||||
it('caps stacked and percent at the fixed limit', () => {
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.Normal, xAxis, plotWidth: 60 }),
|
||||
).toBe(MAX_STACKED_GROUPS);
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.Percent, xAxis, plotWidth: 60 }),
|
||||
).toBe(MAX_STACKED_GROUPS);
|
||||
});
|
||||
|
||||
it('fits side by side so the narrowest bar stays 3 px wide', () => {
|
||||
// 120 px over six slots: 20 px a bucket, six 3 px bars.
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.None, xAxis, plotWidth: 120 }),
|
||||
).toBe(6);
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.None, xAxis, plotWidth: 6 }),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupIntoOther', () => {
|
||||
const args = {
|
||||
labels: ['a', 'b', 'c', 'd'],
|
||||
counts: [
|
||||
[1, 1],
|
||||
[5, 5],
|
||||
[2, 0],
|
||||
[3, 3],
|
||||
],
|
||||
colors: ['#a', '#b', '#c', '#d'],
|
||||
visible: [true, true, true, true],
|
||||
otherColor: '#999',
|
||||
};
|
||||
|
||||
it('draws every visible group when they fit', () => {
|
||||
const drawn = groupIntoOther({ ...args, maxGroups: 4 });
|
||||
|
||||
expect(drawn.labels).toStrictEqual(['a', 'b', 'c', 'd']);
|
||||
expect(drawn.otherGroupCount).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the largest, in legend order, and sums the rest exactly', () => {
|
||||
const drawn = groupIntoOther({ ...args, maxGroups: 3 });
|
||||
|
||||
expect(drawn.labels).toStrictEqual(['b', 'd', OTHER_GROUP_LABEL]);
|
||||
expect(drawn.sourceIndexes).toStrictEqual([1, 3, null]);
|
||||
expect(drawn.counts[2]).toStrictEqual([3, 1]);
|
||||
expect(drawn.colors[2]).toBe('#999');
|
||||
expect(drawn).toMatchObject({ otherGroupCount: 2, visibleGroupCount: 4 });
|
||||
});
|
||||
|
||||
it('re-ranks without hidden groups, which never reach Other', () => {
|
||||
const drawn = groupIntoOther({
|
||||
...args,
|
||||
visible: [true, false, true, true],
|
||||
maxGroups: 2,
|
||||
});
|
||||
|
||||
expect(drawn.labels).toStrictEqual(['d', OTHER_GROUP_LABEL]);
|
||||
expect(drawn.sourceIndexes).toStrictEqual([3, null]);
|
||||
expect(drawn.counts[1]).toStrictEqual([3, 1]);
|
||||
expect(drawn.visibleGroupCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClickedGroup', () => {
|
||||
const series = ['a', 'b', 'c'].map((label) => ({
|
||||
label,
|
||||
counts: [label === 'a' ? 5 : 1],
|
||||
}));
|
||||
const drawn = groupIntoOther({
|
||||
labels: ['a', 'b', 'c'],
|
||||
counts: series.map(({ counts }) => counts),
|
||||
colors: ['#a', '#b', '#c'],
|
||||
visible: [true, true, true],
|
||||
maxGroups: 2,
|
||||
otherColor: '#999',
|
||||
});
|
||||
|
||||
it('maps a drawn bar back to its source group and colour', () => {
|
||||
expect(resolveClickedGroup(drawn, 0, series)).toStrictEqual({
|
||||
series: series[0],
|
||||
color: '#a',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses Other and empty space', () => {
|
||||
expect(resolveClickedGroup(drawn, 1, series)).toBeNull();
|
||||
expect(resolveClickedGroup(drawn, null, series)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps by source index, not label, when groups share a legend', () => {
|
||||
const twins = [
|
||||
{ label: 'Other', groupLabels: { pod: 'a' }, counts: [1] },
|
||||
{ label: 'Other', groupLabels: { pod: 'b' }, counts: [2] },
|
||||
];
|
||||
const drawnTwins = groupIntoOther({
|
||||
labels: twins.map(({ label }) => label),
|
||||
counts: twins.map(({ counts }) => counts),
|
||||
colors: ['#a', '#b'],
|
||||
visible: [true, true],
|
||||
maxGroups: 2,
|
||||
otherColor: '#999',
|
||||
});
|
||||
|
||||
expect(resolveClickedGroup(drawnTwins, 1, twins)).toStrictEqual({
|
||||
series: twins[1],
|
||||
color: '#b',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { toAlphaHex } from 'lib/uPlotV2/utils/fillOpacity';
|
||||
|
||||
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
export function withOpacity(color: string, opacity: number): string {
|
||||
return HEX_COLOR.test(color) ? `${color}${toAlphaHex(opacity)}` : color;
|
||||
}
|
||||
|
||||
/** Neutral, so the summed remainder never reads as one more group. */
|
||||
export function resolveOtherColor(isDarkMode: boolean): string {
|
||||
return isDarkMode ? Color.BG_VANILLA_400 : Color.BG_SLATE_50;
|
||||
}
|
||||
|
||||
export function resolveSeriesColor(
|
||||
label: string,
|
||||
isDarkMode: boolean,
|
||||
customColors?: Record<string, string>,
|
||||
): string {
|
||||
return (
|
||||
customColors?.[label] ??
|
||||
generateColor(
|
||||
label,
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
// Same surface as the shared and heatmap tooltips.
|
||||
.container {
|
||||
--gutter: 14px;
|
||||
--row-inset: 6px;
|
||||
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--l2-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 240px;
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
|
||||
// Same as the heatmap tooltip's bucket row.
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-6) var(--gutter);
|
||||
}
|
||||
|
||||
.titleBucket,
|
||||
.titleCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--popover-foreground);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.titleSeparator {
|
||||
flex: 1;
|
||||
border-width: 0.5px;
|
||||
border-style: dashed;
|
||||
border-color: currentColor;
|
||||
min-width: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
// One grid for the heading and every row, so their columns line up.
|
||||
.rows {
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(0, 1fr) repeat(3, auto);
|
||||
column-gap: var(--spacing-6);
|
||||
row-gap: var(--spacing-1);
|
||||
align-content: start;
|
||||
padding: var(--spacing-4) var(--spacing-4) var(--spacing-6);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.columnHeader,
|
||||
.rows .row {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
// The shared item's own `gap` would otherwise shift the subgrid off the parent.
|
||||
column-gap: var(--spacing-6);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.columnHeader {
|
||||
padding: 0 6px var(--spacing-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
|
||||
> span:nth-child(n + 2) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.columnLabel {
|
||||
grid-column: 1 / 3;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rows .row {
|
||||
border-radius: var(--radius);
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&[data-is-active='true'] {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
> :nth-child(n + 3) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
formatRowLabel,
|
||||
resolveRowIndex,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import TooltipPinnedBadge from 'lib/uPlotV2/components/Tooltip/components/TooltipPinnedBadge/TooltipPinnedBadge';
|
||||
import ItemStyles from 'lib/uPlotV2/components/Tooltip/components/TooltipItem/TooltipItem.module.scss';
|
||||
|
||||
import { resolveNearestSeries } from '../../layout';
|
||||
import {
|
||||
buildDistributionTooltipRows,
|
||||
formatPercent,
|
||||
resolveGroupColumn,
|
||||
} from './distributionTooltipContent';
|
||||
import { DistributionTooltipProps } from './types';
|
||||
|
||||
import Styles from './DistributionTooltip.module.scss';
|
||||
|
||||
/**
|
||||
* The bucket comes from the live cursor, not uPlot's `cursor.idx`: that snaps to the
|
||||
* nearest bucket centre, which on uneven widths is often the neighbour.
|
||||
*/
|
||||
export default function DistributionTooltip({
|
||||
uPlotInstance,
|
||||
xAxis,
|
||||
layout,
|
||||
labels,
|
||||
colors,
|
||||
counts,
|
||||
formatBound,
|
||||
isPinned,
|
||||
dismiss,
|
||||
renderTooltipFooter,
|
||||
}: DistributionTooltipProps): JSX.Element | null {
|
||||
// Read outside the memo: uPlot mutates the same instance on every move.
|
||||
const { left = -10, top = -10 } = uPlotInstance.cursor;
|
||||
|
||||
const hover = useMemo(() => {
|
||||
if (left < 0 || top < 0) {
|
||||
return null;
|
||||
}
|
||||
const x = uPlotInstance.posToVal(left, 'x');
|
||||
const bucket = resolveRowIndex(xAxis.edges, x);
|
||||
if (bucket === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bucket,
|
||||
series: resolveNearestSeries(
|
||||
layout,
|
||||
bucket,
|
||||
x,
|
||||
uPlotInstance.posToVal(top, 'y'),
|
||||
),
|
||||
};
|
||||
}, [left, top, uPlotInstance, xAxis, layout]);
|
||||
|
||||
const groupColumn = useMemo(() => resolveGroupColumn(labels), [labels]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
hover
|
||||
? buildDistributionTooltipRows({
|
||||
labels,
|
||||
colors,
|
||||
counts,
|
||||
bucket: hover.bucket,
|
||||
hoveredSeries: hover.series,
|
||||
formatLabel: groupColumn.format,
|
||||
})
|
||||
: [],
|
||||
[hover, labels, colors, counts, groupColumn],
|
||||
);
|
||||
|
||||
if (!hover) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bucket = xAxis.rows[hover.bucket];
|
||||
const bucketTotal = rows.reduce((sum, row) => sum + row.count, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
|
||||
data-pinned={isPinned}
|
||||
data-testid="distribution-tooltip"
|
||||
>
|
||||
<div className={Styles.title}>
|
||||
<span
|
||||
className={Styles.titleBucket}
|
||||
data-testid="distribution-tooltip-range"
|
||||
>
|
||||
{formatRowLabel(bucket, formatBound)}
|
||||
</span>
|
||||
<span className={Styles.titleSeparator} />
|
||||
<span
|
||||
className={Styles.titleCount}
|
||||
data-testid="distribution-tooltip-total"
|
||||
>
|
||||
{bucketTotal.toLocaleString()}
|
||||
</span>
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} />
|
||||
|
||||
<div className={Styles.rows}>
|
||||
<div className={Styles.columnHeader}>
|
||||
<span
|
||||
className={Styles.columnLabel}
|
||||
title={groupColumn.heading}
|
||||
data-testid="distribution-tooltip-group-heading"
|
||||
>
|
||||
{groupColumn.heading}
|
||||
</span>
|
||||
<span>Count</span>
|
||||
<span>% Total</span>
|
||||
<span data-testid="distribution-tooltip-cumulative-heading">
|
||||
{bucket.isOverflow ? 'All' : `≤ ${formatBound(bucket.upper)}`}
|
||||
</span>
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.seriesIndex}
|
||||
className={cx(ItemStyles.uplotTooltipItem, Styles.row)}
|
||||
data-is-active={row.isHovered}
|
||||
data-testid="distribution-tooltip-row"
|
||||
data-hovered={row.isHovered}
|
||||
>
|
||||
<span
|
||||
className={ItemStyles.uplotTooltipItemMarker}
|
||||
style={{ borderColor: row.color, backgroundColor: row.color }}
|
||||
/>
|
||||
<span className={ItemStyles.uplotTooltipItemLabel} title={row.label}>
|
||||
{row.label}
|
||||
</span>
|
||||
<span className={ItemStyles.uplotTooltipItemValue}>
|
||||
{row.count.toLocaleString()}
|
||||
</span>
|
||||
<span className={ItemStyles.uplotTooltipItemValue}>
|
||||
{formatPercent(row.share)}
|
||||
</span>
|
||||
<span className={ItemStyles.uplotTooltipItemValue}>
|
||||
{formatPercent(row.cumulativeShare)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
resolveDistributionBars,
|
||||
resolveDistributionXAxis,
|
||||
} from 'lib/visualization/charts/Distribution/layout';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import DistributionTooltip from '../DistributionTooltip';
|
||||
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40]);
|
||||
const counts = [
|
||||
[1, 4, 2, 0],
|
||||
[0, 6, 1, 1],
|
||||
];
|
||||
const layout = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
/** Pixels are values: the cursor at (1.5, 2) is over the first group in (10, 20]. */
|
||||
function plotAt(left: number, top: number): uPlot {
|
||||
const plot: Partial<uPlot> = {
|
||||
cursor: { left, top },
|
||||
posToVal: (position: number) => position,
|
||||
};
|
||||
return plot as uPlot;
|
||||
}
|
||||
|
||||
function renderTooltip(uPlotInstance: uPlot): void {
|
||||
render(
|
||||
<DistributionTooltip
|
||||
uPlotInstance={uPlotInstance}
|
||||
isPinned={false}
|
||||
dismiss={jest.fn()}
|
||||
xAxis={xAxis}
|
||||
layout={layout}
|
||||
labels={['{service.name="cart"}', '{service.name="checkout"}']}
|
||||
colors={['#f00', '#0f0']}
|
||||
counts={counts}
|
||||
formatBound={(value): string => `${value} ms`}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('DistributionTooltip', () => {
|
||||
it('names the bucket under the cursor and its total', () => {
|
||||
renderTooltip(plotAt(1.5, 2));
|
||||
|
||||
expect(screen.getByTestId('distribution-tooltip-range')).toHaveTextContent(
|
||||
'10 ms – 20 ms',
|
||||
);
|
||||
expect(screen.getByTestId('distribution-tooltip-total')).toHaveTextContent(
|
||||
'10',
|
||||
);
|
||||
});
|
||||
|
||||
it('heads the columns with the group-by key and the bucket bound', () => {
|
||||
renderTooltip(plotAt(1.5, 2));
|
||||
|
||||
expect(
|
||||
screen.getByTestId('distribution-tooltip-group-heading'),
|
||||
).toHaveTextContent('service.name');
|
||||
expect(
|
||||
screen.getByTestId('distribution-tooltip-cumulative-heading'),
|
||||
).toHaveTextContent('≤ 20 ms');
|
||||
});
|
||||
|
||||
it('lists every group in legend order, the hovered one active', () => {
|
||||
renderTooltip(plotAt(1.5, 2));
|
||||
|
||||
const rows = screen.getAllByTestId('distribution-tooltip-row');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toHaveTextContent('cart457%71%');
|
||||
expect(rows[0]).toHaveAttribute('data-is-active', 'true');
|
||||
expect(rows[1]).toHaveTextContent('checkout675%75%');
|
||||
expect(rows[1]).toHaveAttribute('data-is-active', 'false');
|
||||
});
|
||||
|
||||
it('renders nothing off the plot', () => {
|
||||
renderTooltip(plotAt(-1, -1));
|
||||
|
||||
expect(screen.queryByTestId('distribution-tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
buildDistributionTooltipRows,
|
||||
formatPercent,
|
||||
resolveGroupColumn,
|
||||
} from '../distributionTooltipContent';
|
||||
|
||||
describe('formatPercent', () => {
|
||||
it('rounds, and never shows a real share as 0%', () => {
|
||||
expect(formatPercent(0.256)).toBe('26%');
|
||||
expect(formatPercent(0.004)).toBe('<1%');
|
||||
expect(formatPercent(0)).toBe('0%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGroupColumn', () => {
|
||||
it('names the column after a shared single key and shows its values', () => {
|
||||
const column = resolveGroupColumn([
|
||||
'{service.name="cart"}',
|
||||
'{service.name="checkout"}',
|
||||
]);
|
||||
|
||||
expect(column.heading).toBe('service.name');
|
||||
expect(column.format('{service.name="cart"}')).toBe('cart');
|
||||
});
|
||||
|
||||
it('keeps full labels for several keys or mixed labels', () => {
|
||||
const several = resolveGroupColumn(['{env="prod", service.name="cart"}']);
|
||||
const mixed = resolveGroupColumn(['{service.name="cart"}', 'Other']);
|
||||
|
||||
expect(several.heading).toBe('Group');
|
||||
expect(several.format('{env="prod", service.name="cart"}')).toBe(
|
||||
'{env="prod", service.name="cart"}',
|
||||
);
|
||||
expect(mixed.heading).toBe('Group');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDistributionTooltipRows', () => {
|
||||
it('gives each drawn group, in legend order, its count, share and cumulative share', () => {
|
||||
const rows = buildDistributionTooltipRows({
|
||||
labels: ['cart', 'checkout'],
|
||||
colors: ['#f00', '#0f0'],
|
||||
counts: [
|
||||
[2, 6, 2],
|
||||
[5, 0, 5],
|
||||
],
|
||||
bucket: 1,
|
||||
hoveredSeries: 0,
|
||||
formatLabel: (label) => label.toUpperCase(),
|
||||
});
|
||||
|
||||
expect(rows).toStrictEqual([
|
||||
{
|
||||
seriesIndex: 0,
|
||||
label: 'CART',
|
||||
color: '#f00',
|
||||
count: 6,
|
||||
share: 0.6,
|
||||
cumulativeShare: 0.8,
|
||||
isHovered: true,
|
||||
},
|
||||
{
|
||||
seriesIndex: 1,
|
||||
label: 'CHECKOUT',
|
||||
color: '#0f0',
|
||||
count: 0,
|
||||
share: 0,
|
||||
cumulativeShare: 0.5,
|
||||
isHovered: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { DistributionGroupColumn, DistributionTooltipRow } from './types';
|
||||
|
||||
const SINGLE_LABEL = /^\{([^=,]+)="([^"]*)"\}$/;
|
||||
|
||||
/** `<1%` rather than `0%`, so a share that exists never reads as none. */
|
||||
export function formatPercent(share: number): string {
|
||||
if (share > 0 && share < 0.01) {
|
||||
return '<1%';
|
||||
}
|
||||
return `${Math.round(share * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the group column after the one group-by key every label shares, showing just
|
||||
* its values; anything else (several keys, Other) keeps the full labels.
|
||||
*/
|
||||
export function resolveGroupColumn(labels: string[]): DistributionGroupColumn {
|
||||
const keys = new Set<string>();
|
||||
labels.forEach((label) => {
|
||||
keys.add(SINGLE_LABEL.exec(label)?.[1] ?? '');
|
||||
});
|
||||
const [key] = [...keys];
|
||||
if (keys.size !== 1 || !key) {
|
||||
return { heading: 'Group', format: (label) => label };
|
||||
}
|
||||
return {
|
||||
heading: key,
|
||||
format: (label) => SINGLE_LABEL.exec(label)?.[2] ?? label,
|
||||
};
|
||||
}
|
||||
|
||||
/** One row per drawn group, in legend order, with its count in `bucket`. */
|
||||
export function buildDistributionTooltipRows({
|
||||
labels,
|
||||
colors,
|
||||
counts,
|
||||
bucket,
|
||||
hoveredSeries,
|
||||
formatLabel,
|
||||
}: {
|
||||
labels: string[];
|
||||
colors: string[];
|
||||
/** `counts[series][bucket]`. */
|
||||
counts: number[][];
|
||||
bucket: number;
|
||||
hoveredSeries: number | null;
|
||||
formatLabel: (label: string) => string;
|
||||
}): DistributionTooltipRow[] {
|
||||
return labels.map((label, series) => {
|
||||
const seriesCounts = counts[series] ?? [];
|
||||
let total = 0;
|
||||
let atOrBelow = 0;
|
||||
seriesCounts.forEach((count, index) => {
|
||||
total += count;
|
||||
if (index <= bucket) {
|
||||
atOrBelow += count;
|
||||
}
|
||||
});
|
||||
const count = seriesCounts[bucket] ?? 0;
|
||||
return {
|
||||
seriesIndex: series,
|
||||
label: formatLabel(label),
|
||||
color: colors[series],
|
||||
count,
|
||||
share: total > 0 ? count / total : 0,
|
||||
cumulativeShare: total > 0 ? atOrBelow / total : 0,
|
||||
isHovered: series === hoveredSeries,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import type {
|
||||
IRenderTooltipFooterArgs,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
|
||||
import type { DistributionBarsLayout } from '../../layout';
|
||||
import type { DistributionXAxis } from '../../types';
|
||||
|
||||
export interface DistributionTooltipRow {
|
||||
/** Index among the drawn series. */
|
||||
seriesIndex: number;
|
||||
label: string;
|
||||
color: string;
|
||||
count: number;
|
||||
/** Of the group's own total over the range. */
|
||||
share: number;
|
||||
/** Of the group's total, at or below this bucket. */
|
||||
cumulativeShare: number;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
export interface DistributionGroupColumn {
|
||||
heading: string;
|
||||
format: (label: string) => string;
|
||||
}
|
||||
|
||||
export interface DistributionTooltipProps extends Pick<
|
||||
TooltipRenderArgs,
|
||||
'uPlotInstance' | 'isPinned' | 'dismiss'
|
||||
> {
|
||||
xAxis: DistributionXAxis;
|
||||
layout: DistributionBarsLayout;
|
||||
labels: string[];
|
||||
colors: string[];
|
||||
/** `counts[series][bucket]`. */
|
||||
counts: number[][];
|
||||
formatBound: (value: number) => string;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
DistributionType,
|
||||
DrawStyle,
|
||||
StackMode,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { createVariableWidthBarsPathBuilder } from 'lib/uPlotV2/paths/variableWidthBars';
|
||||
import { resolveRowIndex } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { createHatchPattern } from 'lib/uPlotV2/plugins/HeatmapPlugin/paint';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { withOpacity } from './colors';
|
||||
import {
|
||||
drawPercentileMarkers,
|
||||
MARKER_HIT_PX,
|
||||
PercentileMarkerHitArea,
|
||||
resolveHoveredMarker,
|
||||
} from './drawPercentileMarkers';
|
||||
import {
|
||||
DistributionBarsLayout,
|
||||
LOG_Y_BASE,
|
||||
resolveNearestSeries,
|
||||
thinLabelledSplits,
|
||||
} from './layout';
|
||||
import { DistributionPercentile, groupPercentileMarkers } from './percentiles';
|
||||
import { DistributionXAxis } from './types';
|
||||
import { createBoundFormatter } from './utils';
|
||||
|
||||
/** Minimum space between two x tick labels, in CSS pixels. */
|
||||
const X_LABEL_GAP_PX = 12;
|
||||
const BAR_GAP_PX = 1;
|
||||
const BAR_OPACITY = 0.85;
|
||||
const OVERFLOW_BAR_OPACITY = 0.4;
|
||||
/** Every group but the hovered one fades to this. */
|
||||
const DIMMED_BAR_OPACITY = 0.25;
|
||||
const DIMMED_OVERFLOW_BAR_OPACITY = 0.12;
|
||||
const OVERFLOW_AXIS_LABEL = '∞';
|
||||
/** Headroom above the tallest bar: a fraction on linear, a factor on log. Marker
|
||||
* labels sit at the top of the plot, so they take more. */
|
||||
const LINEAR_HEADROOM = 1.1;
|
||||
const LINEAR_HEADROOM_WITH_MARKERS = 1.3;
|
||||
const LOG_HEADROOM = 2;
|
||||
const LOG_HEADROOM_WITH_MARKERS = 6;
|
||||
|
||||
function resolveYRange({
|
||||
yMax,
|
||||
isLogY,
|
||||
stack,
|
||||
hasMarkers,
|
||||
}: {
|
||||
yMax: number;
|
||||
isLogY: boolean;
|
||||
stack: StackMode;
|
||||
hasMarkers: boolean;
|
||||
}): [number, number] {
|
||||
if (stack === StackMode.Percent) {
|
||||
return [0, hasMarkers ? 100 * LINEAR_HEADROOM_WITH_MARKERS : 100];
|
||||
}
|
||||
if (isLogY) {
|
||||
const headroom = hasMarkers ? LOG_HEADROOM_WITH_MARKERS : LOG_HEADROOM;
|
||||
return [LOG_Y_BASE, Math.max(yMax, LOG_Y_BASE) * headroom];
|
||||
}
|
||||
const headroom = hasMarkers ? LINEAR_HEADROOM_WITH_MARKERS : LINEAR_HEADROOM;
|
||||
return [0, yMax > 0 ? yMax * headroom : 1];
|
||||
}
|
||||
|
||||
export interface BuildDistributionConfigArgs {
|
||||
id: string;
|
||||
xAxis: DistributionXAxis;
|
||||
layout: DistributionBarsLayout;
|
||||
labels: string[];
|
||||
colors: string[];
|
||||
stack: StackMode;
|
||||
isLogY: boolean;
|
||||
isDarkMode: boolean;
|
||||
/** Drawn across the plot, labelled at the top. */
|
||||
markers: DistributionPercentile[];
|
||||
/** Unit of the bucket bounds; counts take none. */
|
||||
xAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}
|
||||
|
||||
export function buildDistributionConfig({
|
||||
id,
|
||||
xAxis,
|
||||
layout,
|
||||
labels,
|
||||
colors,
|
||||
stack,
|
||||
isLogY,
|
||||
isDarkMode,
|
||||
markers,
|
||||
xAxisUnit,
|
||||
decimalPrecision,
|
||||
}: BuildDistributionConfigArgs): UPlotConfigBuilder {
|
||||
const builder = new UPlotConfigBuilder({ id });
|
||||
// uPlot's focus picks the series nearest in value, meaningless for stacked bars; the
|
||||
// setCursor hook focuses the one under the cursor itself. Alpha 1 leaves the
|
||||
// dimming to the bar fills.
|
||||
builder.setFocus({ alpha: 1 });
|
||||
// The x range is pinned to the buckets, so a drag-zoom would only flash a box.
|
||||
builder.setCursor({
|
||||
focus: { prox: -1 },
|
||||
points: { show: false },
|
||||
drag: { x: false, y: false },
|
||||
});
|
||||
|
||||
const formatBound = createBoundFormatter(xAxisUnit, decimalPrecision);
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: false,
|
||||
auto: false,
|
||||
range: (): [number, number] => [xAxis.min, xAxis.max],
|
||||
});
|
||||
const yRange = resolveYRange({
|
||||
yMax: layout.yMax,
|
||||
isLogY,
|
||||
stack,
|
||||
hasMarkers: markers.length > 0,
|
||||
});
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
auto: false,
|
||||
distribution: isLogY ? DistributionType.Logarithmic : DistributionType.Linear,
|
||||
range: (): [number, number] => yRange,
|
||||
});
|
||||
|
||||
const axisSplits =
|
||||
xAxis.overflowSplit === null
|
||||
? xAxis.splits
|
||||
: [...xAxis.splits, xAxis.overflowSplit];
|
||||
const splitLabels = new Map<number, string>();
|
||||
xAxis.splits.forEach((split, index) => {
|
||||
splitLabels.set(split, formatBound(xAxis.rows[index].upper));
|
||||
});
|
||||
if (xAxis.overflowSplit !== null) {
|
||||
splitLabels.set(xAxis.overflowSplit, OVERFLOW_AXIS_LABEL);
|
||||
}
|
||||
|
||||
const labelOf = (split: number): string =>
|
||||
splitLabels.get(split) ?? formatBound(xAxis.toBucketValue(split));
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
// Thinned by each label's own width: bound labels run from `5 s` to
|
||||
// `16.67 mins`, so no fixed spacing keeps them apart.
|
||||
splits: (self, axisIdx): number[] => {
|
||||
const visible = axisSplits.filter(
|
||||
(split) => split >= xAxis.min && split <= xAxis.max,
|
||||
);
|
||||
const font = self.axes[axisIdx]?.font?.[0];
|
||||
if (font) {
|
||||
self.ctx.font = font;
|
||||
}
|
||||
const kept = thinLabelledSplits({
|
||||
positions: visible.map((split) => self.valToPos(split, 'x')),
|
||||
widths: visible.map(
|
||||
(split) => self.ctx.measureText(labelOf(split)).width / uPlot.pxRatio,
|
||||
),
|
||||
gap: X_LABEL_GAP_PX,
|
||||
});
|
||||
return kept.map((index) => visible[index]);
|
||||
},
|
||||
values: (_, splits): string[] => splits.map(labelOf),
|
||||
});
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit: stack === StackMode.Percent ? 'percent' : 'short',
|
||||
// The marker headroom runs a share axis past 100%, which labels nothing real.
|
||||
...(stack === StackMode.Percent && {
|
||||
filter: (_self: uPlot, splits: number[]): Array<number | null> =>
|
||||
splits.map((split) => (split <= 100 ? split : null)),
|
||||
}),
|
||||
});
|
||||
|
||||
let hatchPattern: CanvasPattern | null | undefined;
|
||||
const getHatchPattern = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
): CanvasPattern | null => {
|
||||
hatchPattern ??= createHatchPattern(ctx, isDarkMode);
|
||||
return hatchPattern;
|
||||
};
|
||||
|
||||
// Read by every series' fill on each draw; a change redraws the paths.
|
||||
const hover: {
|
||||
series: number | null;
|
||||
focused: number | null;
|
||||
marker: number | null;
|
||||
} = { series: null, focused: null, marker: null };
|
||||
const plotMarkers = groupPercentileMarkers(
|
||||
markers.filter(
|
||||
({ axisValue }) => axisValue >= xAxis.min && axisValue <= xAxis.max,
|
||||
),
|
||||
);
|
||||
let markerHitAreas: PercentileMarkerHitArea[] = [];
|
||||
const isDimmed = (seriesIndex: number): boolean =>
|
||||
hover.series !== null && hover.series !== seriesIndex;
|
||||
|
||||
labels.forEach((label, seriesIndex) => {
|
||||
const color = colors[seriesIndex];
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
label,
|
||||
drawStyle: DrawStyle.Bar,
|
||||
pathBuilder: createVariableWidthBarsPathBuilder({
|
||||
getBars: () => layout.rects[seriesIndex] ?? [],
|
||||
fill: () =>
|
||||
withOpacity(
|
||||
color,
|
||||
isDimmed(seriesIndex) ? DIMMED_BAR_OPACITY : BAR_OPACITY,
|
||||
),
|
||||
hatchedFill: () =>
|
||||
withOpacity(
|
||||
color,
|
||||
isDimmed(seriesIndex)
|
||||
? DIMMED_OVERFLOW_BAR_OPACITY
|
||||
: OVERFLOW_BAR_OPACITY,
|
||||
),
|
||||
getHatchPattern,
|
||||
gapPx: BAR_GAP_PX,
|
||||
}),
|
||||
lineColor: color,
|
||||
lineWidth: 0,
|
||||
colorMapping: {},
|
||||
isDarkMode,
|
||||
showPoints: false,
|
||||
spanGaps: false,
|
||||
});
|
||||
});
|
||||
|
||||
builder.addHook('draw', (u: uPlot): void => {
|
||||
markerHitAreas = drawPercentileMarkers(
|
||||
u,
|
||||
plotMarkers,
|
||||
formatBound,
|
||||
isDarkMode,
|
||||
hover.marker,
|
||||
);
|
||||
});
|
||||
|
||||
// Registered as a hook, not a plugin, so it runs before TooltipPlugin's: the tooltip
|
||||
// only shows for a focused series, and must find it focused when it positions.
|
||||
builder.addHook('setCursor', (u: uPlot): void => {
|
||||
const { left = -1, top = -1 } = u.cursor;
|
||||
const isOnPlot = left >= 0 && top >= 0;
|
||||
const x = isOnPlot ? u.posToVal(left, 'x') : Number.NaN;
|
||||
const bucket = isOnPlot ? resolveRowIndex(xAxis.edges, x) : null;
|
||||
const marker = isOnPlot
|
||||
? resolveHoveredMarker(markerHitAreas, left, top, MARKER_HIT_PX)
|
||||
: null;
|
||||
// Over a marker, the bars neither dim nor show their tooltip.
|
||||
const hovered =
|
||||
bucket === null || marker !== null
|
||||
? null
|
||||
: resolveNearestSeries(layout, bucket, x, u.posToVal(top, 'y'));
|
||||
// An empty bucket still gets a tooltip, as a bar chart's zero column does; with no
|
||||
// nearest group to name, the first stands in.
|
||||
const isEmptyBucket = bucket !== null && marker === null && hovered === null;
|
||||
const focused = isEmptyBucket && labels.length > 0 ? 0 : hovered;
|
||||
|
||||
if (focused !== hover.focused) {
|
||||
hover.focused = focused;
|
||||
u.setSeries(focused === null ? null : focused + 1, { focus: true });
|
||||
}
|
||||
if (marker !== hover.marker) {
|
||||
// The crosshair would cut through the marker's badges.
|
||||
u.root
|
||||
.querySelectorAll<HTMLElement>('.u-cursor-x, .u-cursor-y')
|
||||
.forEach((line) => {
|
||||
line.style.visibility = marker === null ? '' : 'hidden';
|
||||
});
|
||||
}
|
||||
if (hovered !== hover.series || marker !== hover.marker) {
|
||||
hover.series = hovered;
|
||||
hover.marker = marker;
|
||||
u.redraw(true, false);
|
||||
}
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
DistributionPercentile,
|
||||
formatPercentileBadge,
|
||||
formatPercentileValue,
|
||||
} from './percentiles';
|
||||
|
||||
const MARKER_DASH: [number, number] = [3, 3];
|
||||
const MARKER_FONT_SIZE_PX = 9;
|
||||
/** Space between a badge's name and its diamond's edges. */
|
||||
const MARKER_BADGE_PADDING_PX = 2;
|
||||
const MARKER_BADGE_GAP_PX = 2;
|
||||
const MARKER_VALUE_FONT_SIZE_PX = 11;
|
||||
/** How far from a marker line the cursor still hovers it, in CSS pixels. */
|
||||
export const MARKER_HIT_PX = 4;
|
||||
const MARKER_PILL_PADDING_X_PX = 6;
|
||||
const MARKER_PILL_PADDING_Y_PX = 3;
|
||||
const MARKER_PILL_RADIUS_PX = 4;
|
||||
/** Height of the pill's notch pointing up at the line; also its gap from the axis. */
|
||||
const MARKER_PILL_POINTER_PX = 4;
|
||||
|
||||
interface HitRect {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/** A drawn marker's hover targets, in CSS pixels relative to the plot. */
|
||||
export interface PercentileMarkerHitArea {
|
||||
x: number;
|
||||
badges: HitRect[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker under `(x, y)`: one whose badge it is over, else the line nearest
|
||||
* `x` within `tolerance`. `null` when none is.
|
||||
*/
|
||||
export function resolveHoveredMarker(
|
||||
areas: PercentileMarkerHitArea[],
|
||||
x: number,
|
||||
y: number,
|
||||
tolerance: number,
|
||||
): number | null {
|
||||
const overBadge = areas.findIndex(({ badges }) =>
|
||||
badges.some(
|
||||
(badge) =>
|
||||
x >= badge.left && x <= badge.right && y >= badge.top && y <= badge.bottom,
|
||||
),
|
||||
);
|
||||
if (overBadge !== -1) {
|
||||
return overBadge;
|
||||
}
|
||||
let nearest: number | null = null;
|
||||
let nearestDistance = tolerance;
|
||||
areas.forEach((area, index) => {
|
||||
const distance = Math.abs(area.x - x);
|
||||
if (distance <= nearestDistance) {
|
||||
nearest = index;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
});
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacks marker badges into rows so none overlap: each goes in the first row whose
|
||||
* last badge ends before it starts. Input and output are in the same pixel space.
|
||||
*/
|
||||
export function layoutMarkerLabels(
|
||||
labels: Array<{ x: number; width: number }>,
|
||||
gap: number,
|
||||
): number[] {
|
||||
const rowEnds: number[] = [];
|
||||
const order = labels
|
||||
.map((label, index) => ({ ...label, index }))
|
||||
.sort((a, b) => a.x - b.x);
|
||||
const rows = new Array<number>(labels.length).fill(0);
|
||||
|
||||
order.forEach(({ x, width, index }) => {
|
||||
let row = rowEnds.findIndex((end) => x >= end + gap);
|
||||
if (row === -1) {
|
||||
row = rowEnds.length;
|
||||
rowEnds.push(0);
|
||||
}
|
||||
rowEnds[row] = x + width;
|
||||
rows[index] = row;
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** A square diamond centred on `(x, y)` with half-diagonal `r`, filled then outlined. */
|
||||
function drawDiamond(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
r: number,
|
||||
fill: string,
|
||||
stroke: string,
|
||||
): void {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y - r);
|
||||
ctx.lineTo(x + r, y);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.lineTo(x - r, y);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = fill;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashed lines across the plot with a diamond badge at each end naming the
|
||||
* percentile, one per group on the same spot; badges stack into rows so none overlap. The hovered marker is
|
||||
* emphasised and shows its value on the x axis. Returns each marker's hover targets.
|
||||
*/
|
||||
export function drawPercentileMarkers(
|
||||
u: uPlot,
|
||||
markers: DistributionPercentile[][],
|
||||
formatBound: (value: number) => string,
|
||||
isDarkMode: boolean,
|
||||
hovered: number | null,
|
||||
): PercentileMarkerHitArea[] {
|
||||
if (markers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const { ctx, bbox } = u;
|
||||
const { pxRatio } = uPlot;
|
||||
const color = isDarkMode ? themeColors.white : themeColors.black;
|
||||
const background = isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100;
|
||||
const bottom = bbox.top + bbox.height;
|
||||
|
||||
ctx.save();
|
||||
ctx.font = `600 ${MARKER_FONT_SIZE_PX * pxRatio}px Inter, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const named = markers.map((group) => {
|
||||
const text = formatPercentileBadge(group);
|
||||
return { text, width: ctx.measureText(text).width };
|
||||
});
|
||||
// One square diamond for all, fitting the widest name's box: |dx| + |dy| ≤ r at
|
||||
// its corners. Cap height stands in for the text's height.
|
||||
const r =
|
||||
(Math.max(...named.map(({ width }) => width)) +
|
||||
MARKER_FONT_SIZE_PX * pxRatio * 0.7) /
|
||||
2 +
|
||||
MARKER_BADGE_PADDING_PX * pxRatio;
|
||||
const badges = markers.map((group, index) => ({
|
||||
text: named[index].text,
|
||||
x: u.valToPos(group[0].axisValue, 'x', true),
|
||||
}));
|
||||
const rows = layoutMarkerLabels(
|
||||
badges.map(({ x }) => ({ x: x - r, width: 2 * r })),
|
||||
MARKER_BADGE_GAP_PX * pxRatio,
|
||||
);
|
||||
const rowStep = 2 * r + MARKER_BADGE_GAP_PX * pxRatio;
|
||||
const badgeCentres = (index: number): [number, number] => {
|
||||
const offset = r + rows[index] * rowStep;
|
||||
return [bbox.top + offset, bottom - offset];
|
||||
};
|
||||
|
||||
const drawMarker = (index: number): void => {
|
||||
const { text, x } = badges[index];
|
||||
const isHovered = index === hovered;
|
||||
|
||||
ctx.globalAlpha = isHovered ? 1 : 0.55;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = pxRatio;
|
||||
ctx.setLineDash(MARKER_DASH.map((dash) => dash * pxRatio));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, bbox.top);
|
||||
ctx.lineTo(x, bottom);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.setLineDash([]);
|
||||
badgeCentres(index).forEach((y) => {
|
||||
drawDiamond(ctx, x, y, r, isHovered ? color : background, color);
|
||||
ctx.fillStyle = isHovered ? background : color;
|
||||
ctx.fillText(text, x, y);
|
||||
});
|
||||
|
||||
if (!isHovered) {
|
||||
return;
|
||||
}
|
||||
// The value sits on the x axis under the line, over the tick labels, in a pill
|
||||
// whose notch points up at the line.
|
||||
ctx.font = `500 ${MARKER_VALUE_FONT_SIZE_PX * pxRatio}px Inter, sans-serif`;
|
||||
const value = formatPercentileValue(markers[index], formatBound);
|
||||
const padX = MARKER_PILL_PADDING_X_PX * pxRatio;
|
||||
const padY = MARKER_PILL_PADDING_Y_PX * pxRatio;
|
||||
const pointer = MARKER_PILL_POINTER_PX * pxRatio;
|
||||
const width = ctx.measureText(value).width + 2 * padX;
|
||||
const height = MARKER_VALUE_FONT_SIZE_PX * pxRatio + 2 * padY;
|
||||
const left = Math.min(Math.max(x - width / 2, 0), ctx.canvas.width - width);
|
||||
const top = bottom + 2 * pointer;
|
||||
const notchX = Math.min(
|
||||
Math.max(x, left + pointer + MARKER_PILL_RADIUS_PX * pxRatio),
|
||||
left + width - pointer - MARKER_PILL_RADIUS_PX * pxRatio,
|
||||
);
|
||||
// A halo in the chart's background hides the tick labels it overlaps.
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(left - padX, top, width + 2 * padX, height + 2 * padY);
|
||||
ctx.fillStyle = isDarkMode ? Color.BG_SLATE_400 : Color.BG_INK_400;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(left, top, width, height, MARKER_PILL_RADIUS_PX * pxRatio);
|
||||
ctx.moveTo(notchX - pointer, top);
|
||||
ctx.lineTo(notchX, top - pointer);
|
||||
ctx.lineTo(notchX + pointer, top);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.fillStyle = Color.BG_VANILLA_100;
|
||||
ctx.fillText(value, left + width / 2, top + height / 2);
|
||||
};
|
||||
|
||||
// The hovered one last, so it draws over its neighbours.
|
||||
badges.forEach((_, index) => index !== hovered && drawMarker(index));
|
||||
if (hovered !== null && hovered < badges.length) {
|
||||
drawMarker(hovered);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
return badges.map(({ x }, index) => {
|
||||
const toPlot = (value: number, origin: number): number =>
|
||||
(value - origin) / pxRatio;
|
||||
return {
|
||||
x: toPlot(x, bbox.left),
|
||||
badges: badgeCentres(index).map((y) => ({
|
||||
left: toPlot(x - r, bbox.left),
|
||||
right: toPlot(x + r, bbox.left),
|
||||
top: toPlot(y - r, bbox.top),
|
||||
bottom: toPlot(y + r, bbox.top),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
cropHeatmapYAxis,
|
||||
resolveHeatmapYAxis,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { HeatmapAxisScale } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
import { DistributionBarRect, DistributionXAxis } from './types';
|
||||
|
||||
/** Bottom of a log count axis: a bar has to start somewhere above zero. */
|
||||
export const LOG_Y_BASE = 1;
|
||||
|
||||
/**
|
||||
* N bounds give N + 1 buckets: `≤ first`, the finite ones, and `> last`. Every bucket
|
||||
* is equally wide: bucket `i` spans `[i, i + 1]`, and ticks still name the bounds.
|
||||
*/
|
||||
export function resolveDistributionXAxis(buckets: number[]): DistributionXAxis {
|
||||
const { rows, overflowSplit } = resolveHeatmapYAxis(
|
||||
buckets,
|
||||
HeatmapAxisScale.Linear,
|
||||
);
|
||||
const edges = rows.map((_, index) => index).concat(rows.length);
|
||||
return {
|
||||
rows,
|
||||
edges,
|
||||
splits: rows.slice(0, -1).map((_, index) => index + 1),
|
||||
overflowSplit: overflowSplit === null ? null : rows.length,
|
||||
toBucketValue: (axisValue: number): number => {
|
||||
const index = Math.min(rows.length - 1, Math.max(0, Math.floor(axisValue)));
|
||||
const row = rows[index];
|
||||
return row.lower + (axisValue - index) * (row.upper - row.lower);
|
||||
},
|
||||
min: 0,
|
||||
max: rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sum of each bucket across the visible series. */
|
||||
export function sumVisibleCounts(
|
||||
counts: number[][],
|
||||
visible: boolean[],
|
||||
bucketCount: number,
|
||||
): number[] {
|
||||
const totals = new Array<number>(bucketCount).fill(0);
|
||||
counts.forEach((seriesCounts, seriesIndex) => {
|
||||
if (!visible[seriesIndex]) {
|
||||
return;
|
||||
}
|
||||
for (let bucket = 0; bucket < bucketCount; bucket += 1) {
|
||||
totals[bucket] += seriesCounts[bucket] ?? 0;
|
||||
}
|
||||
});
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** Drops empty buckets past the last occupied one, as the heatmap does. */
|
||||
export function cropDistributionXAxis(
|
||||
axis: DistributionXAxis,
|
||||
totals: number[],
|
||||
): DistributionXAxis {
|
||||
return cropHeatmapYAxis(
|
||||
axis,
|
||||
totals.map((total) => [total]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows the axis window to the buckets overlapping `[min, max]`, never below one.
|
||||
* A bucket straddling a bound stays whole: cutting it would misstate its count.
|
||||
*/
|
||||
export function clampDistributionXAxis(
|
||||
axis: DistributionXAxis,
|
||||
min?: number,
|
||||
max?: number,
|
||||
): DistributionXAxis {
|
||||
const { rows, edges } = axis;
|
||||
let first = edges.indexOf(axis.min);
|
||||
let last = edges.indexOf(axis.max) - 1;
|
||||
while (min !== undefined && first < last && rows[first].upper <= min) {
|
||||
first += 1;
|
||||
}
|
||||
while (max !== undefined && last > first && rows[last].lower >= max) {
|
||||
last -= 1;
|
||||
}
|
||||
return {
|
||||
...axis,
|
||||
overflowSplit: rows[last].isOverflow ? axis.overflowSplit : null,
|
||||
min: edges[first],
|
||||
max: edges[last + 1],
|
||||
};
|
||||
}
|
||||
|
||||
export interface DistributionBarsLayout {
|
||||
/** One list per series, in series order. */
|
||||
rects: DistributionBarRect[][];
|
||||
/** Top of the tallest bar inside the axis window, for the count axis range. */
|
||||
yMax: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every bar, in scale space. `normal` stacks the series in each bucket, `percent`
|
||||
* stacks their shares of it, `none` splits the bucket width between them.
|
||||
*/
|
||||
export function resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack,
|
||||
isLogY,
|
||||
}: {
|
||||
xAxis: DistributionXAxis;
|
||||
/** `counts[series][bucket]`. */
|
||||
counts: number[][];
|
||||
stack: StackMode;
|
||||
isLogY: boolean;
|
||||
}): DistributionBarsLayout {
|
||||
const { rows, edges } = xAxis;
|
||||
const rects: DistributionBarRect[][] = counts.map(() => []);
|
||||
const base = isLogY ? LOG_Y_BASE : 0;
|
||||
let yMax = 0;
|
||||
|
||||
rows.forEach((row, bucketIndex) => {
|
||||
const x0 = edges[bucketIndex];
|
||||
const x1 = edges[bucketIndex + 1];
|
||||
// Buckets outside the window are off the plot and mustn't set its height.
|
||||
const isInWindow = x0 >= xAxis.min && x1 <= xAxis.max;
|
||||
const total = counts.reduce(
|
||||
(sum, seriesCounts) => sum + (seriesCounts[bucketIndex] ?? 0),
|
||||
0,
|
||||
);
|
||||
let cumulative = 0;
|
||||
|
||||
counts.forEach((seriesCounts, seriesIndex) => {
|
||||
const count = seriesCounts[bucketIndex] ?? 0;
|
||||
if (count <= 0) {
|
||||
return;
|
||||
}
|
||||
const bar = { bucketIndex, hatched: row.isOverflow };
|
||||
|
||||
if (stack === StackMode.None) {
|
||||
// Below the log base a bar would draw downwards from the axis.
|
||||
if (count <= base) {
|
||||
return;
|
||||
}
|
||||
const width = (x1 - x0) / counts.length;
|
||||
rects[seriesIndex].push({
|
||||
...bar,
|
||||
x0: x0 + seriesIndex * width,
|
||||
x1: x0 + (seriesIndex + 1) * width,
|
||||
y0: base,
|
||||
y1: count,
|
||||
});
|
||||
if (isInWindow) {
|
||||
yMax = Math.max(yMax, count);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = stack === StackMode.Percent ? 100 / total : 1;
|
||||
const y0 = cumulative * scale;
|
||||
cumulative += count;
|
||||
const y1 = cumulative * scale;
|
||||
if (y1 <= base) {
|
||||
return;
|
||||
}
|
||||
rects[seriesIndex].push({
|
||||
...bar,
|
||||
x0,
|
||||
x1,
|
||||
y0: Math.max(y0, base),
|
||||
y1,
|
||||
});
|
||||
if (isInWindow) {
|
||||
yMax = Math.max(yMax, y1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { rects, yMax };
|
||||
}
|
||||
|
||||
/**
|
||||
* The group nearest `(x, y)` vertically in `bucket`, as uPlot focuses bar series;
|
||||
* side by side, only the sub-bar under `x` competes. `null` over an empty bucket.
|
||||
*/
|
||||
export function resolveNearestSeries(
|
||||
layout: DistributionBarsLayout,
|
||||
bucket: number,
|
||||
x: number,
|
||||
y: number,
|
||||
): number | null {
|
||||
const candidates = layout.rects.flatMap((rects, series) =>
|
||||
rects
|
||||
.filter((rect) => rect.bucketIndex === bucket)
|
||||
.map((rect) => ({ rect, series })),
|
||||
);
|
||||
const underX = candidates.filter(({ rect }) => x >= rect.x0 && x < rect.x1);
|
||||
let nearest: number | null = null;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
(underX.length > 0 ? underX : candidates).forEach(({ rect, series }) => {
|
||||
const distance = Math.max(rect.y0 - y, y - rect.y1, 0);
|
||||
if (distance < nearestDistance) {
|
||||
nearest = series;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
});
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexes of the ticks whose centred labels fit without touching, walking from the
|
||||
* right so the `∞` edge survives. Positions and widths share one pixel space.
|
||||
*/
|
||||
export function thinLabelledSplits({
|
||||
positions,
|
||||
widths,
|
||||
gap,
|
||||
}: {
|
||||
positions: number[];
|
||||
widths: number[];
|
||||
gap: number;
|
||||
}): number[] {
|
||||
const kept: number[] = [];
|
||||
let leftEdge = Number.POSITIVE_INFINITY;
|
||||
for (let index = positions.length - 1; index >= 0; index -= 1) {
|
||||
const half = widths[index] / 2;
|
||||
if (positions[index] + half + gap <= leftEdge) {
|
||||
kept.push(index);
|
||||
leftEdge = positions[index] - half;
|
||||
}
|
||||
}
|
||||
return kept.reverse();
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { DistributionXAxis } from './types';
|
||||
|
||||
export interface DistributionPercentile {
|
||||
/** 0–100, as configured. */
|
||||
percentile: number;
|
||||
/** The estimate; in overflow, the last finite bound it is known to exceed. */
|
||||
value: number;
|
||||
/** Where the marker is drawn, in x-scale space. */
|
||||
axisValue: number;
|
||||
isOverflow: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Percentiles of the summed counts, by `histogram_quantile`'s method: find the bucket
|
||||
* holding the target rank and interpolate linearly inside it, with the lowest bucket
|
||||
* starting at zero. So the values match a time-series panel's percentiles.
|
||||
*/
|
||||
export function computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals,
|
||||
percentiles,
|
||||
}: {
|
||||
xAxis: DistributionXAxis;
|
||||
/** Summed visible counts per bucket. */
|
||||
totals: number[];
|
||||
percentiles: readonly number[];
|
||||
}): DistributionPercentile[] {
|
||||
const { rows, edges } = xAxis;
|
||||
const total = totals.reduce((sum, count) => sum + count, 0);
|
||||
if (total <= 0 || rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return percentiles.flatMap<DistributionPercentile>((percentile) => {
|
||||
const rank = (percentile / 100) * total;
|
||||
let below = 0;
|
||||
for (let index = 0; index < rows.length; index += 1) {
|
||||
const count = totals[index] ?? 0;
|
||||
if (count > 0 && below + count >= rank) {
|
||||
const row = rows[index];
|
||||
if (row.isOverflow) {
|
||||
return [
|
||||
{
|
||||
percentile,
|
||||
value: row.lower,
|
||||
axisValue: edges[index],
|
||||
isOverflow: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
const fraction = (rank - below) / count;
|
||||
const lower = row.isUnderflow ? Math.min(0, row.upper) : row.lower;
|
||||
return [
|
||||
{
|
||||
percentile,
|
||||
value: lower + fraction * (row.upper - lower),
|
||||
axisValue: edges[index] + fraction * (edges[index + 1] - edges[index]),
|
||||
isOverflow: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
below += count;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function formatPercentileName(percentile: number): string {
|
||||
return `p${Number(percentile.toFixed(3))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markers that fall on the same spot, sharing one badge: the percentiles landing
|
||||
* in overflow all sit on its edge. Each group is in ascending percentile order.
|
||||
*/
|
||||
export function groupPercentileMarkers(
|
||||
markers: DistributionPercentile[],
|
||||
): DistributionPercentile[][] {
|
||||
const groups = new Map<number, DistributionPercentile[]>();
|
||||
markers.forEach((marker) => {
|
||||
groups.set(marker.axisValue, [
|
||||
...(groups.get(marker.axisValue) ?? []),
|
||||
marker,
|
||||
]);
|
||||
});
|
||||
return [...groups.values()].map((group) =>
|
||||
[...group].sort((a, b) => a.percentile - b.percentile),
|
||||
);
|
||||
}
|
||||
|
||||
/** `p90` for one marker; `p90+` for a group, starting at its lowest percentile. */
|
||||
export function formatPercentileBadge(group: DistributionPercentile[]): string {
|
||||
const name = formatPercentileName(group[0].percentile);
|
||||
return group.length > 1 ? `${name}+` : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* `p99 ≈ 412 ms` for an estimate; `p90, p99 > 5 s` where only the overflow bound is
|
||||
* known. A group shares one spot, so one value and overflow flag.
|
||||
*/
|
||||
export function formatPercentileValue(
|
||||
group: DistributionPercentile[],
|
||||
formatBound: (value: number) => string,
|
||||
): string {
|
||||
const names = group
|
||||
.map(({ percentile }) => formatPercentileName(percentile))
|
||||
.join(', ');
|
||||
const [{ value, isOverflow }] = group;
|
||||
return `${names} ${isOverflow ? '>' : '≈'} ${formatBound(value)}`;
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { DistributionSeries, DistributionXAxis } from './types';
|
||||
|
||||
/** Colours stop being told apart past this many stacked groups. */
|
||||
export const MAX_STACKED_GROUPS = 10;
|
||||
/** Narrowest a side-by-side bar may get, in CSS pixels. */
|
||||
const MIN_SIDE_BY_SIDE_BAR_PX = 3;
|
||||
export const OTHER_GROUP_LABEL = 'Other';
|
||||
|
||||
/**
|
||||
* How many series fit: a fixed cap when stacked, and side by side as many as keep
|
||||
* every bar in the narrowest bucket at least `MIN_SIDE_BY_SIDE_BAR_PX` wide.
|
||||
*/
|
||||
export function resolveMaxDrawnGroups({
|
||||
stack,
|
||||
xAxis,
|
||||
plotWidth,
|
||||
}: {
|
||||
stack: StackMode;
|
||||
xAxis: DistributionXAxis;
|
||||
/** Approximate width of the plotting area, in CSS pixels. */
|
||||
plotWidth: number;
|
||||
}): number {
|
||||
if (stack !== StackMode.None) {
|
||||
return MAX_STACKED_GROUPS;
|
||||
}
|
||||
const span = xAxis.max - xAxis.min;
|
||||
let narrowest = Number.POSITIVE_INFINITY;
|
||||
for (let index = 0; index + 1 < xAxis.edges.length; index += 1) {
|
||||
if (xAxis.edges[index] < xAxis.max) {
|
||||
narrowest = Math.min(narrowest, xAxis.edges[index + 1] - xAxis.edges[index]);
|
||||
}
|
||||
}
|
||||
if (!(span > 0) || !Number.isFinite(narrowest)) {
|
||||
return MAX_STACKED_GROUPS;
|
||||
}
|
||||
const narrowestPx = (plotWidth * narrowest) / span;
|
||||
return Math.min(
|
||||
MAX_STACKED_GROUPS,
|
||||
Math.max(1, Math.floor(narrowestPx / MIN_SIDE_BY_SIDE_BAR_PX)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface DrawnGroups {
|
||||
labels: string[];
|
||||
counts: number[][];
|
||||
colors: string[];
|
||||
/** Each drawn group's index among the source series; `null` for Other. */
|
||||
sourceIndexes: (number | null)[];
|
||||
/** Visible groups summed into the Other series; 0 when none were. */
|
||||
otherGroupCount: number;
|
||||
/** Visible groups in all, drawn or folded. */
|
||||
visibleGroupCount: number;
|
||||
}
|
||||
|
||||
function sum(counts: number[]): number {
|
||||
return counts.reduce((total, count) => total + count, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The visible groups to draw: all of them when they fit, else the largest by total
|
||||
* and one Other series summing the rest. Exact, because bucket counts add. Drawn
|
||||
* groups keep legend order.
|
||||
*/
|
||||
export function groupIntoOther({
|
||||
labels,
|
||||
counts,
|
||||
colors,
|
||||
visible,
|
||||
maxGroups,
|
||||
otherColor,
|
||||
}: {
|
||||
labels: string[];
|
||||
counts: number[][];
|
||||
colors: string[];
|
||||
visible: boolean[];
|
||||
maxGroups: number;
|
||||
otherColor: string;
|
||||
}): DrawnGroups {
|
||||
const visibleIndexes = labels
|
||||
.map((_, index) => index)
|
||||
.filter((index) => visible[index]);
|
||||
|
||||
if (visibleIndexes.length <= maxGroups) {
|
||||
return {
|
||||
labels: visibleIndexes.map((index) => labels[index]),
|
||||
counts: visibleIndexes.map((index) => counts[index]),
|
||||
colors: visibleIndexes.map((index) => colors[index]),
|
||||
sourceIndexes: visibleIndexes,
|
||||
otherGroupCount: 0,
|
||||
visibleGroupCount: visibleIndexes.length,
|
||||
};
|
||||
}
|
||||
|
||||
const keep = new Set(
|
||||
[...visibleIndexes]
|
||||
.sort((a, b) => sum(counts[b]) - sum(counts[a]) || a - b)
|
||||
.slice(0, Math.max(0, maxGroups - 1)),
|
||||
);
|
||||
const kept = visibleIndexes.filter((index) => keep.has(index));
|
||||
const folded = visibleIndexes.filter((index) => !keep.has(index));
|
||||
const bucketCount = Math.max(...folded.map((index) => counts[index].length));
|
||||
const other = Array.from({ length: bucketCount }, (_, bucket) =>
|
||||
folded.reduce((total, index) => total + (counts[index][bucket] ?? 0), 0),
|
||||
);
|
||||
|
||||
return {
|
||||
labels: [...kept.map((index) => labels[index]), OTHER_GROUP_LABEL],
|
||||
counts: [...kept.map((index) => counts[index]), other],
|
||||
colors: [...kept.map((index) => colors[index]), otherColor],
|
||||
sourceIndexes: [...kept, null],
|
||||
otherGroupCount: folded.length,
|
||||
visibleGroupCount: visibleIndexes.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawn group at `drawnIndex` as a source series and its colour; `null` for Other,
|
||||
* which sums several groups no single filter can name.
|
||||
*/
|
||||
export function resolveClickedGroup(
|
||||
drawn: DrawnGroups,
|
||||
drawnIndex: number | null,
|
||||
series: DistributionSeries[],
|
||||
): { series: DistributionSeries; color: string } | null {
|
||||
const source =
|
||||
drawnIndex === null ? null : (drawn.sourceIndexes[drawnIndex] ?? null);
|
||||
if (drawnIndex === null || source === null) {
|
||||
return null;
|
||||
}
|
||||
return { series: series[source], color: drawn.colors[drawnIndex] };
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { VariableWidthBar } from 'lib/uPlotV2/paths/variableWidthBars';
|
||||
import type { HeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
export interface DistributionSeries {
|
||||
label: string;
|
||||
/** Group-by key → value, for drilling into this group. Empty when ungrouped. */
|
||||
groupLabels?: Record<string, string>;
|
||||
/** One count per bucket bound plus the trailing overflow, summed over the range. */
|
||||
counts: number[];
|
||||
}
|
||||
|
||||
/** A click on a group's bar; never on Other, which no filter can name. */
|
||||
export interface DistributionBarClick {
|
||||
series: DistributionSeries;
|
||||
color: string;
|
||||
/** Viewport coordinates of the click. */
|
||||
coordinates: { x: number; y: number };
|
||||
}
|
||||
|
||||
export enum DistributionYScale {
|
||||
Linear = 'linear',
|
||||
Log = 'log',
|
||||
}
|
||||
|
||||
/** The bucket axis in x-scale space. The heatmap's row axis, laid sideways. */
|
||||
export type DistributionXAxis = HeatmapYAxis;
|
||||
|
||||
/** `x` in bucket-axis units, `y` in counts (or percent); the overflow bucket is hatched. */
|
||||
export interface DistributionBarRect extends VariableWidthBar {
|
||||
bucketIndex: number;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { resolveRowIndex } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { type DistributionBarsLayout, resolveNearestSeries } from './layout';
|
||||
import { type DrawnGroups, resolveClickedGroup } from './topGroups';
|
||||
import type {
|
||||
DistributionBarClick,
|
||||
DistributionSeries,
|
||||
DistributionXAxis,
|
||||
} from './types';
|
||||
|
||||
/** Resolves a plot click to the group under it; Other and empty buckets report none. */
|
||||
export function useDistributionBarClick({
|
||||
xAxis,
|
||||
layout,
|
||||
drawn,
|
||||
series,
|
||||
onBarClick,
|
||||
}: {
|
||||
xAxis: DistributionXAxis;
|
||||
layout: DistributionBarsLayout;
|
||||
drawn: DrawnGroups;
|
||||
series: DistributionSeries[];
|
||||
onBarClick?: (click: DistributionBarClick) => void;
|
||||
}): ((data: ChartClickData) => void) | undefined {
|
||||
const onBarClickRef = useRef(onBarClick);
|
||||
onBarClickRef.current = onBarClick;
|
||||
|
||||
const handleClick = useCallback(
|
||||
({
|
||||
xValue,
|
||||
yValue,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
}: ChartClickData): void => {
|
||||
const bucket = resolveRowIndex(xAxis.edges, xValue);
|
||||
const clicked = resolveClickedGroup(
|
||||
drawn,
|
||||
bucket === null
|
||||
? null
|
||||
: resolveNearestSeries(layout, bucket, xValue, yValue),
|
||||
series,
|
||||
);
|
||||
if (clicked) {
|
||||
onBarClickRef.current?.({
|
||||
...clicked,
|
||||
coordinates: { x: absoluteMouseX, y: absoluteMouseY },
|
||||
});
|
||||
}
|
||||
},
|
||||
[xAxis, layout, drawn, series],
|
||||
);
|
||||
|
||||
return onBarClick ? handleClick : undefined;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { DistributionXAxis } from './types';
|
||||
|
||||
export function createBoundFormatter(
|
||||
unit?: string,
|
||||
decimalPrecision?: PrecisionOption,
|
||||
): (value: number) => string {
|
||||
return (value: number): string =>
|
||||
getToolTipValue(String(value), unit, decimalPrecision);
|
||||
}
|
||||
|
||||
/**
|
||||
* `[bucket centres, ...counts per series]`. The series draw from the layout, so the
|
||||
* data only feeds uPlot's cursor and tooltip.
|
||||
*/
|
||||
export function prepareDistributionChartData(
|
||||
xAxis: DistributionXAxis,
|
||||
counts: number[][],
|
||||
): uPlot.AlignedData {
|
||||
const centres = xAxis.rows.map(
|
||||
(_, index) => (xAxis.edges[index] + xAxis.edges[index + 1]) / 2,
|
||||
);
|
||||
return [
|
||||
centres,
|
||||
...counts.map((seriesCounts) =>
|
||||
centres.map((_, index) => seriesCounts[index] ?? 0),
|
||||
),
|
||||
] as unknown as uPlot.AlignedData;
|
||||
}
|
||||
@@ -26,11 +26,6 @@ import {
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import type {
|
||||
DistributionBarClick,
|
||||
DistributionSeries,
|
||||
DistributionYScale,
|
||||
} from 'lib/visualization/charts/Distribution/types';
|
||||
|
||||
interface BaseChartProps {
|
||||
width: number;
|
||||
@@ -172,48 +167,6 @@ export interface HeatmapChartProps {
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-range bucket counts, one series per group, drawn as bars whose widths follow
|
||||
* the bucket bounds. Builds its own config, since `buckets` fully determines the x
|
||||
* axis. `buckets` and `series` must be referentially stable: a new identity rebuilds
|
||||
* the plot.
|
||||
*/
|
||||
export interface DistributionChartProps {
|
||||
/** Widget id; the group selection persists under it. */
|
||||
id: string;
|
||||
/** Ascending. N boundaries describe N+1 buckets, the last one overflow. */
|
||||
buckets: number[];
|
||||
series: DistributionSeries[];
|
||||
width: number;
|
||||
height: number;
|
||||
isDarkMode: boolean;
|
||||
/** Buckets wholly below it are hidden. */
|
||||
xMin?: number;
|
||||
/** Buckets wholly above it are hidden; unset crops trailing empty buckets. */
|
||||
xMax?: number;
|
||||
/** Default linear. Ignored under `percent`, whose axis is a share. */
|
||||
yScale?: DistributionYScale;
|
||||
/** Default normal. `none` draws the groups side by side. */
|
||||
stack?: StackMode;
|
||||
/** Unit of the bucket bounds; counts are always plain numbers. */
|
||||
xAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
/** Hex colours keyed by group label, overriding the automatic palette. */
|
||||
customColors?: Record<string, string>;
|
||||
/** 0–100. Marked across the plot, over the visible groups combined. */
|
||||
percentiles?: readonly number[];
|
||||
/** Fires for a click on a group's bar; not on Other or empty space. */
|
||||
onBarClick?: (click: DistributionBarClick) => void;
|
||||
showLegend?: boolean;
|
||||
legendPosition?: LegendPosition;
|
||||
showTooltip?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
tooltipPortalRoot?: HTMLElement | null;
|
||||
layoutChildren?: React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One resolved pie/donut slice: a display label, its (already parsed) positive
|
||||
* numeric value, and the colour used for the arc + legend swatch.
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getGetRuleByIDQueryKey,
|
||||
getGetRuleHistoryTimelineQueryOptions,
|
||||
invalidateGetRuleByID,
|
||||
invalidateListRules,
|
||||
invalidateListRulesV3,
|
||||
updateRuleByID,
|
||||
useGetRuleByID,
|
||||
useGetRuleHistoryOverallStatus,
|
||||
@@ -443,7 +443,7 @@ export const useAlertRuleClone = ({
|
||||
message: `Success`,
|
||||
});
|
||||
|
||||
await invalidateListRules(queryClient);
|
||||
await invalidateListRulesV3(queryClient);
|
||||
|
||||
params.set(QueryParams.ruleId, response.data.id);
|
||||
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
@@ -494,7 +494,7 @@ export const useAlertRuleUpdate = ({
|
||||
data: { ...existingRule.data, alert: intermediateName },
|
||||
});
|
||||
}
|
||||
void invalidateListRules(queryClient);
|
||||
void invalidateListRulesV3(queryClient);
|
||||
notifications.success({ message: 'Alert renamed successfully' });
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
@@ -17,10 +17,8 @@ import {
|
||||
FIRST_DOWNTIME_NAME,
|
||||
} from './__story_mockdata__/plannedDowntime';
|
||||
|
||||
import {
|
||||
alertRulesResponse,
|
||||
RULE_MAX,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
import { alertRulesPage } from '../../AlertRules/stories/__story_mockdata__/ruleViews';
|
||||
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
|
||||
import { AlertListSubTabs, AlertListTabs } from '../../types';
|
||||
|
||||
const LIST = 'Planned downtime · list';
|
||||
@@ -86,18 +84,20 @@ export const plannedDowntimeMocks = defineStoryMocks({
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v2/rules', (_req, res, ctx) =>
|
||||
values.rulesState === 'error'
|
||||
? res(ctx.status(500), ctx.json({ status: 'error' }))
|
||||
: res(
|
||||
ctx.json(
|
||||
alertRulesResponse(values.silencedRules, {
|
||||
severity: 'mixed',
|
||||
state: 'mixed',
|
||||
}),
|
||||
),
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
if (values.rulesState === 'error') {
|
||||
return res(ctx.status(500), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.json(
|
||||
alertRulesPage(
|
||||
values.silencedRules,
|
||||
{ severity: 'mixed', state: 'mixed' },
|
||||
req.url.searchParams,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
config: () => ({
|
||||
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.PLANNED_DOWNTIME}`,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { listRulesV3Response } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest } from 'msw';
|
||||
|
||||
import {
|
||||
@@ -156,11 +157,13 @@ export const homeMocks = defineStoryMocks({
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: buildAlertRules(values.alertRules),
|
||||
})),
|
||||
'http://localhost/api/v3/rules',
|
||||
response.json((req) =>
|
||||
listRulesV3Response(
|
||||
buildAlertRules(values.alertRules),
|
||||
req.url.searchParams,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
Description: "Execute a composite query over a time range. Supports builder queries (traces, logs, metrics), formulas, trace operators, PromQL, and ClickHouse SQL.",
|
||||
Request: new(qbtypes.QueryRangeRequest),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: []handler.OpenAPIExample{
|
||||
RequestExamples: append([]handler.OpenAPIExample{
|
||||
{
|
||||
Name: "traces_time_series",
|
||||
Summary: "Time series: count spans grouped by service",
|
||||
@@ -598,7 +598,7 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, queryRangeFunctionExamples()...),
|
||||
Response: new(qbtypes.QueryRangeResponse),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
|
||||
202
pkg/apiserver/signozapiserver/querier_examples.go
Normal file
202
pkg/apiserver/signozapiserver/querier_examples.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package signozapiserver
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/http/handler"
|
||||
|
||||
// timeSeriesExample wraps builder query specs in a one hour time series
|
||||
// request, the shape every function example shares.
|
||||
func timeSeriesExample(name, summary, description string, queries ...map[string]any) handler.OpenAPIExample {
|
||||
list := make([]any, 0, len(queries))
|
||||
for _, query := range queries {
|
||||
list = append(list, query)
|
||||
}
|
||||
return handler.OpenAPIExample{
|
||||
Name: name,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Value: map[string]any{
|
||||
"schemaVersion": "v1",
|
||||
"start": 1640995200000,
|
||||
"end": 1640998800000,
|
||||
"requestType": "time_series",
|
||||
"compositeQuery": map[string]any{"queries": list},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func builderQueryEnvelope(spec map[string]any) map[string]any {
|
||||
return map[string]any{"type": "builder_query", "spec": spec}
|
||||
}
|
||||
|
||||
// function builds a function entry with positional arguments; anomaly is the
|
||||
// one function that takes named arguments and is written out in full.
|
||||
func function(name string, args ...any) map[string]any {
|
||||
list := make([]any, 0, len(args))
|
||||
for _, arg := range args {
|
||||
list = append(list, map[string]any{"value": arg})
|
||||
}
|
||||
entry := map[string]any{"name": name}
|
||||
if len(list) > 0 {
|
||||
entry["args"] = list
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// queryRangeFunctionExamples shows every function a builder query can carry,
|
||||
// what its arguments mean and the values they take. Functions run in the
|
||||
// order listed, after the aggregation and before formulas.
|
||||
func queryRangeFunctionExamples() []handler.OpenAPIExample {
|
||||
return []handler.OpenAPIExample{
|
||||
timeSeriesExample(
|
||||
"function_timeshift_week_over_week",
|
||||
"timeShift: this week's error logs next to last week's",
|
||||
"timeShift moves every point of its query forward by the argument, in seconds, so a series read from the past lands on the current timestamps. B here reads the hour exactly seven days (604800 s) before the requested window and comes back on this week's timestamps, so it overlays A on a chart and the formula can subtract it point by point. The argument is positive; it says how far back to read.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
|
||||
}),
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "B",
|
||||
"signal": "logs",
|
||||
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
|
||||
"functions": []any{function("timeShift", 604800)},
|
||||
}),
|
||||
map[string]any{
|
||||
"type": "builder_formula",
|
||||
"spec": map[string]any{"name": "F1", "expression": "A - B"},
|
||||
},
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_fillzero_sparse_errors",
|
||||
"fillZero: a zero at every step with no failed spans",
|
||||
"A count over sparse events returns a point only for steps that had a match, so a chart of failed spans has gaps where nothing failed. fillZero adds a zero at every missing step. With no arguments the server fills the request window at the query's step, which is the common case. The three optional arguments are start, end and step in milliseconds, for callers that want to fill a different grid.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": []any{map[string]any{"expression": "count()", "alias": "failed_spans"}},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "has_error = true"},
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("fillZero")},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_cutoffmin_hide_idle_hosts",
|
||||
"cutOffMin: drop hosts below 5% CPU from the chart",
|
||||
"The four threshold functions take one number. cutOffMin and cutOffMax remove the points below or above the threshold, so an idle host disappears from the chart instead of drawing a line along the bottom. clampMin and clampMax keep the points and replace the value with the threshold, which caps spikes without hiding them. system.cpu.utilization is a ratio, so 0.05 is 5%.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "system.cpu.utilization", "timeAggregation": "avg", "spaceAggregation": "avg"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"groupBy": []any{map[string]any{"name": "host.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("cutOffMin", 0.05)},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_runningdiff_disk_growth",
|
||||
"runningDiff then clampMin: bytes written to disk per step",
|
||||
"runningDiff turns a gauge into the change between consecutive points, so a filesystem usage gauge becomes bytes written per step. It takes no arguments and drops the first point of the window, since that point has nothing to difference against; the server reads one step before the window to keep the first interval. Functions apply in order: clampMin with 0 runs on the differences and replaces the negative value a cleanup or a volume reset produces with zero.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "system.filesystem.usage", "timeAggregation": "latest", "spaceAggregation": "sum"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "state = 'used'"},
|
||||
"groupBy": []any{map[string]any{"name": "host.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("runningDiff"), function("clampMin", 0)},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_ewma_smoothed_latency",
|
||||
"ewma5: smoothed p95 latency",
|
||||
"ewma3, ewma5 and ewma7 replace each point with an exponentially weighted average of the point and the points before it. The number in the name is the window the default weight is derived from: alpha = 2 / (n + 1), so 0.5, 0.333 and 0.25. One optional argument overrides alpha; a value close to 1 follows the raw series, a value close to 0 smooths harder. 0.3 here smooths a little more than ewma5's default.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "http.server.duration.bucket", "spaceAggregation": "p95"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("ewma5", 0.3)},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_median_despiked_request_rate",
|
||||
"median5: request rate without one-step spikes",
|
||||
"median3, median5 and median7 replace each point with the median of the surrounding 3, 5 or 7 points. A spike that lasts one step vanishes, a shift that lasts longer than half the window stays. The first and last half window, 2 points each for median5, have no full window and keep their values, so the newest spike stays visible; a series shorter than the window is returned unchanged. They take no arguments. Use them when the raw series is noisy and the chart is for a trend, not for catching the spike.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "http.server.duration.count", "timeAggregation": "rate", "spaceAggregation": "sum"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("median5")},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_cumulativesum_errors_so_far",
|
||||
"cumulativeSum: 5xx responses since the start of the window",
|
||||
"cumulativeSum replaces each point with the sum of every point up to and including it, so a per-step count becomes a running total whose first point is the first count. It takes no arguments. The total resets with the window, so a longer time range gives a larger final value; it is a view of the window, not a counter.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": []any{map[string]any{"expression": "count()", "alias": "server_errors"}},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "http.response.status_code >= 500"},
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("cumulativeSum")},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_log10_network_bytes",
|
||||
"log10: network throughput across devices that differ by orders of magnitude",
|
||||
"log2 and log10 replace each value with its logarithm, so a loopback device moving kilobytes and an uplink moving gigabytes fit on one chart. They take no arguments. Zero and negative values have no logarithm and are not drawn. absolute, also without arguments, is the usual companion for a signed series before a logarithm.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "system.network.io", "timeAggregation": "rate", "spaceAggregation": "sum"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "direction = 'transmit'"},
|
||||
"groupBy": []any{map[string]any{"name": "device", "fieldContext": "attribute"}},
|
||||
"functions": []any{function("log10")},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_anomaly_request_rate",
|
||||
"anomaly: request rate scored against its daily pattern",
|
||||
"anomaly is the one function with named arguments. seasonality says which past periods predict the current value: hourly, daily or weekly, daily when absent. z_score_threshold is how many standard deviations from the prediction count as an anomaly. It has no default: a request without it gets bounds of zero width, so send it. 3 is the value the UI sends. The response carries, next to the values, a predicted series, an upper and a lower bound series and an anomaly score series. The scoring runs in the enterprise build; the community build returns the series unchanged.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "http.server.duration.count", "timeAggregation": "rate", "spaceAggregation": "sum"},
|
||||
},
|
||||
"stepInterval": "300s",
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{
|
||||
map[string]any{
|
||||
"name": "anomaly",
|
||||
"args": []any{
|
||||
map[string]any{"name": "seasonality", "value": "daily"},
|
||||
map[string]any{"name": "z_score_threshold", "value": 3},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
23
pkg/apiserver/signozapiserver/querier_examples_test.go
Normal file
23
pkg/apiserver/signozapiserver/querier_examples_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
func TestQueryRangeFunctionExamplesAreValidRequests(t *testing.T) {
|
||||
for _, example := range queryRangeFunctionExamples() {
|
||||
t.Run(example.Name, func(t *testing.T) {
|
||||
raw, err := json.Marshal(example.Value)
|
||||
require.NoError(t, err)
|
||||
var req qbtypes.QueryRangeRequest
|
||||
require.NoError(t, json.Unmarshal(raw, &req))
|
||||
assert.NoError(t, req.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,13 @@ import (
|
||||
// NewValidReferences / NewSuggestionsOnLevenshteinDistance to phrase the
|
||||
// "valid <noun> are ..." list consistently across the codebase.
|
||||
const (
|
||||
NounFields = "fields"
|
||||
NounKeys = "keys"
|
||||
NounServices = "services"
|
||||
NounQueryTypes = "query types"
|
||||
NounSignals = "signals"
|
||||
NounReferences = "references"
|
||||
NounFields = "fields"
|
||||
NounKeys = "keys"
|
||||
NounServices = "services"
|
||||
NounQueryTypes = "query types"
|
||||
NounRequestTypes = "request types"
|
||||
NounSignals = "signals"
|
||||
NounReferences = "references"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -915,13 +915,15 @@ func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, q qbtypes
|
||||
// as empty rows that the same request never has when it bypasses the cache.
|
||||
//
|
||||
// PromQL and ClickHouse queries name their own buckets, and a histogram's are
|
||||
// its `le` labels, so only a gauge, sum or summary axis is cut.
|
||||
// its `le` labels, so only a gauge, sum or summary axis and an event axis are cut.
|
||||
func (bc *bucketCache) trimHeatmapAxisToTheWindow(q qbtypes.Query, tsData *qbtypes.TimeSeriesData) {
|
||||
metricQuery, isMetricQuery := q.(*builderQuery[qbtypes.MetricAggregation])
|
||||
if !isMetricQuery {
|
||||
return
|
||||
}
|
||||
if metricQuery.spec.Aggregations[0].Type == metrictypes.HistogramType {
|
||||
switch query := q.(type) {
|
||||
case *builderQuery[qbtypes.MetricAggregation]:
|
||||
if query.spec.Aggregations[0].Type == metrictypes.HistogramType {
|
||||
return
|
||||
}
|
||||
case *builderQuery[qbtypes.LogAggregation], *builderQuery[qbtypes.TraceAggregation]:
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ func newBuilderQuery[T any](
|
||||
func (q *builderQuery[T]) Fingerprint() string {
|
||||
|
||||
if (q.spec.Signal == telemetrytypes.SignalTraces ||
|
||||
q.spec.Signal == telemetrytypes.SignalLogs) && q.kind != qbtypes.RequestTypeTimeSeries {
|
||||
q.spec.Signal == telemetrytypes.SignalLogs) && !q.kind.IsBuiltFromTimeSeries() {
|
||||
// No caching for non-timeseries queries
|
||||
return ""
|
||||
}
|
||||
@@ -88,6 +88,13 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// A limit ranks the groups over the statement window, so the pieces of a
|
||||
// window rank differently from the window. Such a query is never served
|
||||
// from the cache.
|
||||
if (q.spec.Signal == telemetrytypes.SignalLogs || q.spec.Signal == telemetrytypes.SignalTraces) && len(q.spec.GroupBy) > 0 && q.spec.Limit > 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Create a deterministic fingerprint for builder queries
|
||||
// This needs to include all fields that affect the query results
|
||||
parts := []string{q.queryType.StringValue()}
|
||||
@@ -143,6 +150,12 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
parts = append(parts, fmt.Sprintf("aggs=[%s]", strings.Join(aggParts, ",")))
|
||||
}
|
||||
|
||||
// A metrics heatmap carries its axis on the aggregation, which the loop above
|
||||
// already fingerprinted. Logs and traces carry it on the spec.
|
||||
if q.kind.CountsIntoBuckets() && q.spec.Signal != telemetrytypes.SignalMetrics {
|
||||
parts = append(parts, "buckets="+fingerprintHeatmapBucketing(q.spec.BucketOptions.ToHeatmapBucketing()))
|
||||
}
|
||||
|
||||
// Add filter if present
|
||||
if q.spec.Filter != nil && q.spec.Filter.Expression != "" {
|
||||
parts = append(parts, fmt.Sprintf("filter=%s", q.spec.Filter.Expression))
|
||||
|
||||
@@ -191,6 +191,14 @@ func postProcessBuilderQuery[T any](
|
||||
req *qbtypes.QueryRangeRequest,
|
||||
) *qbtypes.Result {
|
||||
|
||||
// A disabled query feeds a formula as a time series, so only an enabled one
|
||||
// came back bucketed.
|
||||
if req.RequestType.CountsIntoBuckets() && !query.Disabled {
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
resolveHeatmapBucketAxis(tsData, query.BucketOptions.ToHeatmapBucketing())
|
||||
}
|
||||
}
|
||||
|
||||
// the statement's top-N already skipped offset groups by their whole-window value; only reorder here
|
||||
result = q.applySeriesLimit(result, query.Limit, 0, query.Order)
|
||||
|
||||
@@ -425,6 +433,12 @@ func (q *querier) processTimeSeriesFormula(
|
||||
return nil
|
||||
}
|
||||
|
||||
formulaSeries, err = applyFormulaHaving(formula, formulaSeries)
|
||||
if err != nil {
|
||||
q.logger.ErrorContext(ctx, "failed to apply formula having", errors.Attr(err), slog.String("formula", formula.Name))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create result for formula
|
||||
formulaResult := &qbtypes.TimeSeriesData{
|
||||
QueryName: formula.Name,
|
||||
@@ -660,6 +674,12 @@ func (q *querier) processScalarFormula(
|
||||
return nil
|
||||
}
|
||||
|
||||
formulaSeries, err = applyFormulaHaving(formula, formulaSeries)
|
||||
if err != nil {
|
||||
q.logger.ErrorContext(ctx, "failed to apply formula having", errors.Attr(err), slog.String("formula", formula.Name))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply ordering (and limit) before converting to scalar format.
|
||||
formulaSeries = qbtypes.ApplySeriesLimit(formulaSeries, formula.Order, formula.Limit, formula.Offset)
|
||||
|
||||
@@ -710,6 +730,35 @@ func (q *querier) processScalarFormula(
|
||||
}
|
||||
}
|
||||
|
||||
// applyFormulaHaving keeps the values the formula having accepts, as SQL HAVING
|
||||
// keeps rows for a builder query, so a series with no values left is removed. It
|
||||
// runs before functions, order, limit, and offset, as HAVING runs before them in SQL.
|
||||
func applyFormulaHaving(formula qbtypes.QueryBuilderFormula, series []*qbtypes.TimeSeries) ([]*qbtypes.TimeSeries, error) {
|
||||
if formula.Having == nil || strings.TrimSpace(formula.Having.Expression) == "" {
|
||||
return series, nil
|
||||
}
|
||||
|
||||
keep, err := querybuilder.CompileFormulaHaving(formula.Name, formula.Having.Expression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
kept := series[:0]
|
||||
for _, s := range series {
|
||||
values := s.Values[:0]
|
||||
for _, value := range s.Values {
|
||||
if keep(value.Value) {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
if len(values) > 0 {
|
||||
s.Values = values
|
||||
kept = append(kept, s)
|
||||
}
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// filterDisabledQueries removes results for disabled queries.
|
||||
func (q *querier) filterDisabledQueries(results map[string]*qbtypes.Result, req *qbtypes.QueryRangeRequest) map[string]*qbtypes.Result {
|
||||
filtered := make(map[string]*qbtypes.Result)
|
||||
|
||||
@@ -212,6 +212,14 @@ func (q *querier) buildQueries(
|
||||
}
|
||||
|
||||
switch query.Type {
|
||||
case qbtypes.QueryTypeFormula:
|
||||
// the querier evaluates a formula after its queries run, so a bad
|
||||
// having must fail here, before they run
|
||||
if formula, ok := query.Spec.(qbtypes.QueryBuilderFormula); ok && formula.Having != nil && strings.TrimSpace(formula.Having.Expression) != "" {
|
||||
if _, err := querybuilder.CompileFormulaHaving(formula.Name, formula.Having.Expression); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
case qbtypes.QueryTypePromQL:
|
||||
promQuery, ok := query.Spec.(qbtypes.PromQuery)
|
||||
if !ok {
|
||||
@@ -249,27 +257,30 @@ func (q *querier) buildQueries(
|
||||
if !ok {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid AI builder query spec %T", query.Spec)
|
||||
}
|
||||
requestType := requestTypeForQuery(req.RequestType, spec.Disabled)
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, builderConfig{})
|
||||
queries[spec.Name] = bq
|
||||
steps[spec.Name] = spec.StepInterval
|
||||
case qbtypes.QueryTypeBuilder:
|
||||
switch spec := query.Spec.(type) {
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
|
||||
requestType := requestTypeForQuery(req.RequestType, spec.Disabled)
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.traceStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.traceStmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, builderConfig{})
|
||||
queries[spec.Name] = bq
|
||||
steps[spec.Name] = spec.StepInterval
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
||||
requestType := requestTypeForQuery(req.RequestType, spec.Disabled)
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
|
||||
stmtBuilder := q.logStmtBuilder
|
||||
if spec.Source == telemetrytypes.SourceAudit {
|
||||
stmtBuilder = q.auditStmtBuilder
|
||||
}
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, stmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, q.builderConfig)
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, stmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, q.builderConfig)
|
||||
queries[spec.Name] = bq
|
||||
steps[spec.Name] = spec.StepInterval
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
|
||||
@@ -280,13 +291,7 @@ func (q *querier) buildQueries(
|
||||
if missingMetricQuerySet[spec.Name] {
|
||||
continue
|
||||
}
|
||||
requestType := req.RequestType
|
||||
if requestType.CountsIntoBuckets() && spec.Disabled {
|
||||
// A disabled query in a heatmap request feeds a formula, and the
|
||||
// formula converts time series into heatmap data, so its inputs
|
||||
// run as time series queries.
|
||||
requestType = qbtypes.RequestTypeTimeSeries
|
||||
}
|
||||
requestType := requestTypeForQuery(req.RequestType, spec.Disabled)
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
|
||||
var bq *builderQuery[qbtypes.MetricAggregation]
|
||||
@@ -922,32 +927,25 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
|
||||
copy(argsCopy, qt.args)
|
||||
return newchSQLQuery(q.logger, q.telemetryStore, queryCopy, argsCopy, timeRange, qt.kind, qt.vars)
|
||||
|
||||
// A builder query's window was moved into the shifted clock when it was
|
||||
// built, so a range taken from that window must not be shifted again.
|
||||
case *builderQuery[qbtypes.TraceAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
// reuse the original query's statement builder and type so an AI query
|
||||
// keeps its AI builder and cache key
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, qt.builderConfig)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, qt.builderConfig)
|
||||
|
||||
case *builderQuery[qbtypes.LogAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
shiftStmtBuilder := q.logStmtBuilder
|
||||
if qt.spec.Source == telemetrytypes.SourceAudit {
|
||||
shiftStmtBuilder = q.auditStmtBuilder
|
||||
}
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, q.builderConfig)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, q.builderConfig)
|
||||
|
||||
case *builderQuery[qbtypes.MetricAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
if qt.spec.Source == telemetrytypes.SourceMeter {
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, builderConfig{})
|
||||
}
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, builderConfig{})
|
||||
case *traceOperatorQuery:
|
||||
specCopy := qt.spec.Copy()
|
||||
return &traceOperatorQuery{
|
||||
@@ -1195,6 +1193,17 @@ func clampStep(qe *qbtypes.QueryEnvelope, recommended, min uint64, warnings *[]s
|
||||
}
|
||||
}
|
||||
|
||||
// requestTypeForQuery downgrades a disabled query in a heatmap request to a
|
||||
// time series one. A disabled query feeds a formula rather than the chart, and
|
||||
// the formula is what turns time series into cells, so its inputs state no
|
||||
// bucket axis of their own.
|
||||
func requestTypeForQuery(requestType qbtypes.RequestType, disabled bool) qbtypes.RequestType {
|
||||
if requestType.CountsIntoBuckets() && disabled {
|
||||
return qbtypes.RequestTypeTimeSeries
|
||||
}
|
||||
return requestType
|
||||
}
|
||||
|
||||
// extractShiftFromBuilderQuery extracts the shift value from timeShift function if present.
|
||||
func extractShiftFromBuilderQuery[T any](spec qbtypes.QueryBuilderQuery[T]) int64 {
|
||||
for _, fn := range spec.Functions {
|
||||
|
||||
166
pkg/querybuilder/formula_having.go
Normal file
166
pkg/querybuilder/formula_having.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/havingexpression/grammar"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
// CompileFormulaHaving turns the having expression of a formula into a filter on
|
||||
// its values. The querier computes a formula in Go after ClickHouse returns, so the
|
||||
// having cannot run as SQL; it gets the same validation and messages as a
|
||||
// builder-query having, with the formula name and __result as the only references.
|
||||
func CompileFormulaHaving(formulaName, expression string) (func(value float64) bool, error) {
|
||||
rewriter := &HavingExpressionRewriter{columnMap: map[string]string{
|
||||
formulaName: qbtypes.DefaultOrderByKey,
|
||||
qbtypes.DefaultOrderByKey: qbtypes.DefaultOrderByKey,
|
||||
}}
|
||||
_, tree, err := rewriter.parseAndValidate(expression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return compileOrExpression(tree.Expression().OrExpression()), nil
|
||||
}
|
||||
|
||||
func compileOrExpression(ctx grammar.IOrExpressionContext) func(float64) bool {
|
||||
andExpressions := ctx.AllAndExpression()
|
||||
conditions := make([]func(float64) bool, len(andExpressions))
|
||||
for i, andExpression := range andExpressions {
|
||||
conditions[i] = compileAndExpression(andExpression)
|
||||
}
|
||||
|
||||
return func(value float64) bool {
|
||||
for _, condition := range conditions {
|
||||
if condition(value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func compileAndExpression(ctx grammar.IAndExpressionContext) func(float64) bool {
|
||||
primaries := ctx.AllPrimary()
|
||||
conditions := make([]func(float64) bool, len(primaries))
|
||||
for i, primary := range primaries {
|
||||
conditions[i] = compilePrimary(primary)
|
||||
}
|
||||
|
||||
return func(value float64) bool {
|
||||
for _, condition := range conditions {
|
||||
if !condition(value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func compilePrimary(ctx grammar.IPrimaryContext) func(float64) bool {
|
||||
var condition func(float64) bool
|
||||
if ctx.OrExpression() != nil {
|
||||
condition = compileOrExpression(ctx.OrExpression())
|
||||
} else {
|
||||
condition = compileComparison(ctx.Comparison())
|
||||
}
|
||||
|
||||
if ctx.NOT() == nil {
|
||||
return condition
|
||||
}
|
||||
return func(value float64) bool { return !condition(value) }
|
||||
}
|
||||
|
||||
func compileComparison(ctx grammar.IComparisonContext) func(float64) bool {
|
||||
left := compileOperand(ctx.Operand(0))
|
||||
|
||||
if ctx.IN() != nil {
|
||||
signedNumbers := ctx.InList().AllSignedNumber()
|
||||
list := make([]float64, len(signedNumbers))
|
||||
for i, signedNumber := range signedNumbers {
|
||||
list[i] = parseHavingNumber(signedNumber.GetText())
|
||||
}
|
||||
notIn := ctx.NOT() != nil
|
||||
return func(value float64) bool { return slices.Contains(list, left(value)) != notIn }
|
||||
}
|
||||
|
||||
right := compileOperand(ctx.Operand(1))
|
||||
compOp := ctx.CompOp()
|
||||
switch {
|
||||
case compOp.EQUALS() != nil:
|
||||
return func(value float64) bool { return left(value) == right(value) }
|
||||
case compOp.NOT_EQUALS() != nil, compOp.NEQ() != nil:
|
||||
return func(value float64) bool { return left(value) != right(value) }
|
||||
case compOp.LT() != nil:
|
||||
return func(value float64) bool { return left(value) < right(value) }
|
||||
case compOp.LE() != nil:
|
||||
return func(value float64) bool { return left(value) <= right(value) }
|
||||
case compOp.GT() != nil:
|
||||
return func(value float64) bool { return left(value) > right(value) }
|
||||
default:
|
||||
return func(value float64) bool { return left(value) >= right(value) }
|
||||
}
|
||||
}
|
||||
|
||||
func compileOperand(ctx grammar.IOperandContext) func(float64) float64 {
|
||||
if ctx.Operand() == nil {
|
||||
return compileTerm(ctx.Term())
|
||||
}
|
||||
|
||||
left := compileOperand(ctx.Operand())
|
||||
right := compileTerm(ctx.Term())
|
||||
if ctx.MINUS() != nil {
|
||||
return func(value float64) float64 { return left(value) - right(value) }
|
||||
}
|
||||
return func(value float64) float64 { return left(value) + right(value) }
|
||||
}
|
||||
|
||||
func compileTerm(ctx grammar.ITermContext) func(float64) float64 {
|
||||
if ctx.Term() == nil {
|
||||
return compileFactor(ctx.Factor())
|
||||
}
|
||||
|
||||
left := compileTerm(ctx.Term())
|
||||
right := compileFactor(ctx.Factor())
|
||||
switch {
|
||||
case ctx.SLASH() != nil:
|
||||
return func(value float64) float64 { return left(value) / right(value) }
|
||||
case ctx.PERCENT() != nil:
|
||||
return func(value float64) float64 { return math.Mod(left(value), right(value)) }
|
||||
default:
|
||||
return func(value float64) float64 { return left(value) * right(value) }
|
||||
}
|
||||
}
|
||||
|
||||
func compileFactor(ctx grammar.IFactorContext) func(float64) float64 {
|
||||
if ctx.Factor() != nil {
|
||||
inner := compileFactor(ctx.Factor())
|
||||
if ctx.MINUS() != nil {
|
||||
return func(value float64) float64 { return -inner(value) }
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
if ctx.Operand() != nil {
|
||||
return compileOperand(ctx.Operand())
|
||||
}
|
||||
|
||||
if number := ctx.Atom().NUMBER(); number != nil {
|
||||
constant := parseHavingNumber(number.GetText())
|
||||
return func(float64) float64 { return constant }
|
||||
}
|
||||
|
||||
// validation rejects function calls, strings, and unknown names, so the atom
|
||||
// names the formula value
|
||||
return func(value float64) float64 { return value }
|
||||
}
|
||||
|
||||
// ParseFloat reads every form the lexer admits as a number; a literal outside the
|
||||
// float64 range becomes the nearest value ParseFloat returns.
|
||||
func parseHavingNumber(text string) float64 {
|
||||
number, _ := strconv.ParseFloat(text, 64)
|
||||
return number
|
||||
}
|
||||
86
pkg/querybuilder/formula_having_test.go
Normal file
86
pkg/querybuilder/formula_having_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
func TestCompileFormulaHaving_Matches(t *testing.T) {
|
||||
values := []float64{-2, 0, 1, 2.5, 3, 10}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
expression string
|
||||
want []float64
|
||||
}{
|
||||
{name: "Name_GreaterThan", expression: "F1 > 2", want: []float64{2.5, 3, 10}},
|
||||
{name: "Result_LessOrEqual", expression: "__result <= 1", want: []float64{-2, 0, 1}},
|
||||
{name: "DoubleEquals", expression: "F1 == 3", want: []float64{3}},
|
||||
{name: "AngleNotEquals", expression: "F1 <> 0", want: []float64{-2, 1, 2.5, 3, 10}},
|
||||
{name: "In_Parentheses", expression: "F1 IN (0, 3, -2)", want: []float64{-2, 0, 3}},
|
||||
{name: "NotIn_Brackets", expression: "F1 NOT IN [0, 3, -2]", want: []float64{1, 2.5, 10}},
|
||||
{name: "AndBindsTighterThanOr", expression: "F1 > 0 AND F1 < 3 OR F1 = -2", want: []float64{-2, 1, 2.5}},
|
||||
{name: "ImplicitAnd", expression: "F1 > 0 F1 < 3", want: []float64{1, 2.5}},
|
||||
{name: "LowercaseKeywords", expression: "F1 > 2 and not F1 = 10", want: []float64{2.5, 3}},
|
||||
{name: "NotParenthesized", expression: "NOT (F1 >= 1 AND F1 <= 3)", want: []float64{-2, 0, 10}},
|
||||
{name: "Arithmetic", expression: "F1 * 2 - 1 > 4", want: []float64{3, 10}},
|
||||
{name: "Arithmetic_Parentheses", expression: "(F1 + 2) / 2 >= 2", want: []float64{2.5, 3, 10}},
|
||||
{name: "Modulo", expression: "F1 % 2 = 1", want: []float64{1, 3}},
|
||||
{name: "UnaryMinus", expression: "-F1 > 1", want: []float64{-2}},
|
||||
{name: "Number_Exponent", expression: "F1 >= 1e1", want: []float64{10}},
|
||||
{name: "Number_LeadingDot", expression: "F1 < .5", want: []float64{-2, 0}},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
keep, err := CompileFormulaHaving("F1", testCase.expression)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, keep)
|
||||
|
||||
got := []float64{}
|
||||
for _, value := range values {
|
||||
if keep(value) {
|
||||
got = append(got, value)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, testCase.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileFormulaHaving_Invalid(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
expression string
|
||||
wantErrMsg string
|
||||
wantAdditional []string
|
||||
}{
|
||||
{
|
||||
name: "QueryName_NotAReference",
|
||||
expression: "A > 0",
|
||||
wantErrMsg: "Invalid references in `Having` expression: [A]",
|
||||
wantAdditional: []string{"Valid references are: [F1, __result]"},
|
||||
},
|
||||
{
|
||||
name: "Aggregation_NotAReference",
|
||||
expression: "count() > 0",
|
||||
wantErrMsg: "Invalid references in `Having` expression: [count]",
|
||||
wantAdditional: []string{"Valid references are: [F1, __result]"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
keep, err := CompileFormulaHaving("F1", testCase.expression)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, keep)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
assert.ErrorContains(t, err, testCase.wantErrMsg)
|
||||
assert.Equal(t, testCase.wantAdditional, additionalMessages(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -237,7 +237,13 @@ func (v *havingExpressionRewriteVisitor) visitIdentifier(ctx grammar.IIdentifier
|
||||
return name
|
||||
}
|
||||
|
||||
// rewriteAndValidate is the single-pass implementation used by all RewriteFor* methods.
|
||||
func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string, error) {
|
||||
rewritten, _, err := r.parseAndValidate(expression)
|
||||
return rewritten, err
|
||||
}
|
||||
|
||||
// parseAndValidate is the single-pass implementation used by all RewriteFor* methods.
|
||||
// It also returns the parse tree, which CompileFormulaHaving evaluates in Go.
|
||||
//
|
||||
// Validation layers:
|
||||
// 1. The visitor runs on the parse tree, rewriting and collecting invalid references.
|
||||
@@ -248,7 +254,7 @@ func (v *havingExpressionRewriteVisitor) visitIdentifier(ctx grammar.IIdentifier
|
||||
// 2. String literals in comparison-operand position → descriptive error
|
||||
// ("aggregator results are numeric").
|
||||
// 3. ANTLR syntax errors → error with messages referencing the original token names.
|
||||
func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string, error) {
|
||||
func (r *HavingExpressionRewriter) parseAndValidate(expression string) (string, grammar.IQueryContext, error) {
|
||||
original := strings.TrimSpace(expression)
|
||||
|
||||
// Parse the expression once.
|
||||
@@ -281,7 +287,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
// This is checked before invalid references so that "contains string literals" takes
|
||||
// priority when a bare string literal is also an unresolvable operand.
|
||||
if v.hasStringLiteral {
|
||||
return "", errors.NewInvalidInputf(
|
||||
return "", nil, errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"`Having` expression contains string literals",
|
||||
).WithAdditional("Aggregator results are numeric")
|
||||
@@ -317,7 +323,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
).WithAdditional(
|
||||
"Valid references are: [" + strings.Join(validKeys, ", ") + "]",
|
||||
).WithSuggestions(suggestions...)
|
||||
return "", havingErr
|
||||
return "", nil, havingErr
|
||||
}
|
||||
|
||||
// Layer 3 – ANTLR syntax errors. We parse the original expression, so error messages
|
||||
@@ -343,12 +349,12 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
return havingSuggestion(allSyntaxErrors[0], original)
|
||||
})
|
||||
|
||||
return "", havingErr.WithSuggestiveAdditional(msgs[0], suggestions...)
|
||||
return "", nil, havingErr.WithSuggestiveAdditional(msgs[0], suggestions...)
|
||||
}
|
||||
return "", havingErr.WithAdditional(msgs...)
|
||||
return "", nil, havingErr.WithAdditional(msgs...)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return result, tree, nil
|
||||
}
|
||||
|
||||
// havingSuggestion returns a corrected expression string to show as a suggestion when
|
||||
|
||||
136
pkg/querybuilder/heatmap.go
Normal file
136
pkg/querybuilder/heatmap.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// heatmapBucketValueAlias names the value a heatmap's bounds are cut from. The
|
||||
// bounds read it several times, so it is declared once as a WITH expression
|
||||
// alias rather than repeated.
|
||||
const heatmapBucketValueAlias = "__bucket_value"
|
||||
|
||||
// RenderHeatmapBucketExprs renders the bucket (min, max] that valueExpr falls
|
||||
// in. Only the bucket under everything the axis covers is open below, and only
|
||||
// the one over it is open above.
|
||||
//
|
||||
// valueExpr is repeated several times in each expression, so it has to be free
|
||||
// of placeholders — HeatmapBucketColumns passes an alias for that reason.
|
||||
func RenderHeatmapBucketExprs(bucketing qbtypes.HeatmapBucketing, valueExpr string) (minExpr, maxExpr string) {
|
||||
if bucketing.Kind == qbtypes.BucketsKindLinear {
|
||||
return renderLinearBucketExprs(bucketing, valueExpr)
|
||||
}
|
||||
return renderLogBucketExprs(valueExpr)
|
||||
}
|
||||
|
||||
func renderLinearBucketExprs(bucketing qbtypes.HeatmapBucketing, valueExpr string) (string, string) {
|
||||
maxValue := FormatHeatmapFloat(bucketing.MaxValue)
|
||||
numBuckets := strconv.Itoa(bucketing.NumBuckets)
|
||||
index := fmt.Sprintf("least(greatest(ceil(%s * %s / %s), 1), %s)", valueExpr, numBuckets, maxValue, numBuckets)
|
||||
|
||||
minExpr := fmt.Sprintf(
|
||||
"multiIf(%s <= 0, toFloat64('-Inf'), %s > %s, toFloat64(%s), (%s - 1) * %s / %s)",
|
||||
valueExpr, valueExpr, maxValue, maxValue, index, maxValue, numBuckets,
|
||||
)
|
||||
maxExpr := fmt.Sprintf(
|
||||
"multiIf(%s <= 0, toFloat64(0), %s > %s, toFloat64('+Inf'), %s * %s / %s)",
|
||||
valueExpr, valueExpr, maxValue, index, maxValue, numBuckets,
|
||||
)
|
||||
return minExpr, maxExpr
|
||||
}
|
||||
|
||||
// ClickHouse buckets at MaxLogScale whatever HeatmapBucketing.LogScale asks for;
|
||||
// postprocessing folds the axis down afterwards.
|
||||
func renderLogBucketExprs(valueExpr string) (string, string) {
|
||||
bucketsPerDoubling := FormatHeatmapFloat(math.Exp2(qbtypes.MaxLogScale))
|
||||
lowest := FormatHeatmapFloat(qbtypes.MinLogUpperBound)
|
||||
highest := FormatHeatmapFloat(qbtypes.MaxLogUpperBound)
|
||||
|
||||
minExpr := fmt.Sprintf(
|
||||
"multiIf(%s <= 0, toFloat64('-Inf'), %s <= %s, toFloat64(0), %s > %s, toFloat64(%s), pow(2, (ceil(log2(%s) * %s) - 1) / %s))",
|
||||
valueExpr, valueExpr, lowest, valueExpr, highest, highest, valueExpr, bucketsPerDoubling, bucketsPerDoubling,
|
||||
)
|
||||
maxExpr := fmt.Sprintf(
|
||||
"multiIf(%s <= 0, toFloat64(0), %s <= %s, %s, %s > %s, toFloat64('+Inf'), pow(2, ceil(log2(%s) * %s) / %s))",
|
||||
valueExpr, valueExpr, lowest, lowest, valueExpr, highest, valueExpr, bucketsPerDoubling, bucketsPerDoubling,
|
||||
)
|
||||
return minExpr, maxExpr
|
||||
}
|
||||
|
||||
// FormatHeatmapFloat renders a float64 as the shortest literal that reads back
|
||||
// as the same value, so an upper bound computed from it is identical on every
|
||||
// row.
|
||||
func FormatHeatmapFloat(v float64) string {
|
||||
return strconv.FormatFloat(v, 'g', -1, 64)
|
||||
}
|
||||
|
||||
// HeatmapFieldMayBeAbsent reports whether some row can lack key. A key that
|
||||
// resolves to a table column every row carries, such as a span's duration,
|
||||
// cannot, as the column is read first, and an EXISTS on it would drop its
|
||||
// zeros instead.
|
||||
func HeatmapFieldMayBeAbsent(
|
||||
ctx context.Context,
|
||||
info qbtypes.QueryInfo,
|
||||
storage qbtypes.Storage,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
) (bool, error) {
|
||||
resolved, err := Resolve(ctx, info, storage, key, qbtypes.FilterOperatorUnknown, nil, keys)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, field := range resolved.Fields {
|
||||
read, err := LogicalRead(ctx, info, storage, field)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if read.WhenAbsent == qbtypes.AlwaysPresent {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// HeatmapBucketColumns adds the bucket a row's value falls in to sb's select
|
||||
// list, as the (min, max] pair a heatmap statement reports. key is the field
|
||||
// the axis is cut from, already adjusted for context and data type.
|
||||
//
|
||||
// The bounds read the value through an expression alias rather than repeating
|
||||
// it, so the returned fragment has to be declared alongside the caller's CTEs.
|
||||
// It carries no arguments of its own.
|
||||
//
|
||||
// A row without a numeric value counts as 0.
|
||||
func HeatmapBucketColumns(
|
||||
ctx context.Context,
|
||||
info qbtypes.QueryInfo,
|
||||
storage qbtypes.Storage,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
bucketOptions *qbtypes.BucketOptions,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
) (valueFragment string, groupByAliases []string, warnings []string, err error) {
|
||||
// Resolved directly rather than through ResolveColumn: a key nothing carries
|
||||
// puts every row at 0, and the warning is the only thing that says why.
|
||||
resolved, err := Resolve(ctx, info, storage, key, qbtypes.FilterOperatorUnknown, nil, keys)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
valueExpr, err := Column(ctx, info, storage, resolved, telemetrytypes.FieldDataTypeFloat64)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
bucketMin, bucketMax := RenderHeatmapBucketExprs(bucketOptions.ToHeatmapBucketing(), heatmapBucketValueAlias)
|
||||
|
||||
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", bucketMin, qbtypes.HeatmapBucketMinColumn)))
|
||||
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", bucketMax, qbtypes.HeatmapBucketMaxColumn)))
|
||||
|
||||
valueFragment = fmt.Sprintf("ifNull(%s, 0) AS %s", valueExpr, heatmapBucketValueAlias)
|
||||
return valueFragment, []string{qbtypes.HeatmapBucketMinColumn, qbtypes.HeatmapBucketMaxColumn}, resolved.Warnings, nil
|
||||
}
|
||||
@@ -110,6 +110,8 @@ func (b *auditQueryStatementBuilder) Build(
|
||||
stmt, err = b.buildListQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
stmt, err = b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeHeatmap, qbtypes.RequestTypeDistribution:
|
||||
stmt, err = b.buildHeatmapTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
stmt, err = b.buildScalarQuery(ctx, orgID, q, query, start, end, keys, false, variables)
|
||||
default:
|
||||
@@ -322,6 +324,48 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, query, start, end, keys, variables, nil)
|
||||
}
|
||||
|
||||
// buildHeatmapTimeSeriesQuery counts rows per (time bucket, group, value
|
||||
// bucket) of the field its aggregation names.
|
||||
func (b *auditQueryStatementBuilder) buildHeatmapTimeSeriesQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
heatmapField, countQuery, err := qbtypes.HeatmapCountQuery(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
|
||||
mayBeAbsent, err := querybuilder.HeatmapFieldMayBeAbsent(ctx, info, b.storage, heatmapField, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mayBeAbsent {
|
||||
countQuery = qbtypes.RequireHeatmapField(countQuery, *heatmapField)
|
||||
}
|
||||
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, countQuery, start, end, keys, variables, heatmapField)
|
||||
}
|
||||
|
||||
// buildTimeSeriesOrHeatmapQuery builds one row per (time bucket, group). A
|
||||
// non-nil heatmapField makes it a heatmap instead: the bucket a row's
|
||||
// heatmapField falls in joins the group keys, so a row is one cell.
|
||||
func (b *auditQueryStatementBuilder) buildTimeSeriesOrHeatmapQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
heatmapField *telemetrytypes.TelemetryFieldKey,
|
||||
) (*qbtypes.Statement, error) {
|
||||
var (
|
||||
cteFragments []string
|
||||
@@ -357,6 +401,17 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
fieldNames = append(fieldNames, sqlbuilder.Escape(clickhousesql.Identifier(gb.Name)))
|
||||
}
|
||||
|
||||
var bucketAliases []string
|
||||
if heatmapField != nil {
|
||||
valueFragment, aliases, warnings, err := querybuilder.HeatmapBucketColumns(ctx, info, b.storage, sb, heatmapField, query.BucketOptions, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cteFragments = append(cteFragments, valueFragment)
|
||||
bucketAliases = aliases
|
||||
columnWarnings = append(columnWarnings, warnings...)
|
||||
}
|
||||
|
||||
allAggChArgs := make([]any, 0)
|
||||
for i, agg := range query.Aggregations {
|
||||
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(ctx, orgID, start, end, agg.Expression, uint64(query.StepInterval.Seconds()), keys)
|
||||
@@ -393,6 +448,7 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
|
||||
sb.GroupBy("ts")
|
||||
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
|
||||
sb.GroupBy(bucketAliases...)
|
||||
if query.Having != nil && query.Having.Expression != "" {
|
||||
rewriter := querybuilder.NewHavingExpressionRewriter()
|
||||
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
|
||||
@@ -420,6 +476,7 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
} else {
|
||||
sb.GroupBy("ts")
|
||||
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
|
||||
sb.GroupBy(bucketAliases...)
|
||||
if query.Having != nil && query.Having.Expression != "" {
|
||||
rewriter := querybuilder.NewHavingExpressionRewriter()
|
||||
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -224,3 +225,59 @@ func TestStatementBuilder(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatementBuilderHeatmap(t *testing.T) {
|
||||
statementBuilder := newTestAuditStatementBuilder(t)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
expected qbtypes.Statement
|
||||
}{
|
||||
{
|
||||
name: "AttributeField_GroupByMaterialized_LinearAxis_RequiresField",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Source: telemetrytypes.SourceAudit,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "http.response.status_code"}},
|
||||
Filter: &qbtypes.Filter{Expression: "signoz.audit.resource.kind = 'dashboard'"},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.audit.action"}}},
|
||||
BucketOptions: &qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLinear, Spec: qbtypes.LinearBucketsSpec{MaxValue: 500, NumBuckets: 5}},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'http.response.status_code'), toFloat64(attributes_number['http.response.status_code']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$action_exists` = true, `attribute_string_signoz$$audit$$action`, NULL)) AS `signoz.audit.action`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 500, toFloat64(500), (least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) - 1) * 500 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 500, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) * 500 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'http.response.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, `signoz.audit.action`, __bucket_min, __bucket_max",
|
||||
Args: []any{"dashboard", "%signoz.audit.resource.kind%", "%signoz.audit.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Distribution_AttributeField_SameStatementAsHeatmap",
|
||||
requestType: qbtypes.RequestTypeDistribution,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Source: telemetrytypes.SourceAudit,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "http.response.status_code"}},
|
||||
Filter: &qbtypes.Filter{Expression: "signoz.audit.resource.kind = 'dashboard'"},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.audit.action"}}},
|
||||
BucketOptions: &qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLinear, Spec: qbtypes.LinearBucketsSpec{MaxValue: 500, NumBuckets: 5}},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'http.response.status_code'), toFloat64(attributes_number['http.response.status_code']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$action_exists` = true, `attribute_string_signoz$$audit$$action`, NULL)) AS `signoz.audit.action`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 500, toFloat64(500), (least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) - 1) * 500 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 500, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) * 500 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'http.response.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, `signoz.audit.action`, __bucket_min, __bucket_max",
|
||||
Args: []any{"dashboard", "%signoz.audit.resource.kind%", "%signoz.audit.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
statement, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, testCase.requestType, testCase.query, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expected.Query, statement.Query)
|
||||
assert.Equal(t, testCase.expected.Args, statement.Args)
|
||||
assert.Equal(t, testCase.expected.Warnings, statement.Warnings)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,8 @@ func (b *logQueryStatementBuilder) Build(
|
||||
stmt, err = b.buildListQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
stmt, err = b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeHeatmap, qbtypes.RequestTypeDistribution:
|
||||
stmt, err = b.buildHeatmapTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
stmt, err = b.buildScalarQuery(ctx, orgID, q, query, start, end, keys, false, variables)
|
||||
default:
|
||||
@@ -442,6 +444,48 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, query, start, end, keys, variables, nil)
|
||||
}
|
||||
|
||||
// buildHeatmapTimeSeriesQuery counts rows per (time bucket, group, value
|
||||
// bucket) of the field its aggregation names.
|
||||
func (b *logQueryStatementBuilder) buildHeatmapTimeSeriesQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
heatmapField, countQuery, err := qbtypes.HeatmapCountQuery(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
|
||||
mayBeAbsent, err := querybuilder.HeatmapFieldMayBeAbsent(ctx, info, b.storage, columnKey(heatmapField), keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mayBeAbsent {
|
||||
countQuery = qbtypes.RequireHeatmapField(countQuery, *heatmapField)
|
||||
}
|
||||
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, countQuery, start, end, keys, variables, heatmapField)
|
||||
}
|
||||
|
||||
// buildTimeSeriesOrHeatmapQuery builds one row per (time bucket, group). A
|
||||
// non-nil heatmapField makes it a heatmap instead: the bucket a row's
|
||||
// heatmapField falls in joins the group keys, so a row is one cell.
|
||||
func (b *logQueryStatementBuilder) buildTimeSeriesOrHeatmapQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
heatmapField *telemetrytypes.TelemetryFieldKey,
|
||||
) (*qbtypes.Statement, error) {
|
||||
|
||||
var (
|
||||
cteFragments []string
|
||||
@@ -481,6 +525,19 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
fieldNames = append(fieldNames, sqlbuilder.Escape(fieldAlias))
|
||||
}
|
||||
|
||||
var bucketAliases []string
|
||||
if heatmapField != nil {
|
||||
valueFragment, aliases, warnings, err := querybuilder.HeatmapBucketColumns(
|
||||
ctx, info, b.storage, sb, columnKey(heatmapField), query.BucketOptions, keys,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cteFragments = append(cteFragments, valueFragment)
|
||||
bucketAliases = aliases
|
||||
columnWarnings = append(columnWarnings, warnings...)
|
||||
}
|
||||
|
||||
// Aggregations
|
||||
allAggChArgs := make([]any, 0)
|
||||
for i, agg := range query.Aggregations {
|
||||
@@ -527,6 +584,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
// Group by all dimensions
|
||||
sb.GroupBy("ts")
|
||||
sb.GroupBy(fieldNames...)
|
||||
sb.GroupBy(bucketAliases...)
|
||||
if query.Having != nil && query.Having.Expression != "" {
|
||||
// Rewrite having expression to use SQL column names
|
||||
rewriter := querybuilder.NewHavingExpressionRewriter()
|
||||
@@ -561,6 +619,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
} else {
|
||||
sb.GroupBy("ts")
|
||||
sb.GroupBy(fieldNames...)
|
||||
sb.GroupBy(bucketAliases...)
|
||||
if query.Having != nil && query.Having.Expression != "" {
|
||||
rewriter := querybuilder.NewHavingExpressionRewriter()
|
||||
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -1271,3 +1272,119 @@ func newSkipResourceFingerprintLogsBuilder(
|
||||
statementbuilder.Config{SkipResourceFingerprint: statementbuilder.SkipResourceFingerprint{Enabled: skipEnable, Threshold: threshold}},
|
||||
)
|
||||
}
|
||||
|
||||
func TestStatementBuilderHeatmap(t *testing.T) {
|
||||
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||
startTs := uint64(releaseTime.UnixNano()) + uint64(24*time.Hour.Nanoseconds())
|
||||
endTs := uint64(releaseTime.UnixNano()) + uint64(48*time.Hour.Nanoseconds())
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
expected qbtypes.Statement
|
||||
}{
|
||||
{
|
||||
name: "AttributeField_LogAxis_RequiresField",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "duration"}},
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'cartservice'"},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'duration'), toFloat64(attributes_number['duration']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'duration') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, __bucket_min, __bucket_max",
|
||||
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705397400), uint64(1705485600), "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AttributeField_GroupByWithLimit_LinearAxis_RequiresFieldInLimit",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "http.status_code"}},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
Limit: 5,
|
||||
BucketOptions: &qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLinear, Spec: qbtypes.LinearBucketsSpec{MaxValue: 600, NumBuckets: 6}},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH ifNull(multiIf(mapContains(attributes_number, 'http.status_code'), toFloat64(attributes_number['http.status_code']), NULL), 0) AS __bucket_value, __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE mapContains(attributes_number, 'http.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 600, toFloat64(600), (least(greatest(ceil(__bucket_value * 6 / 600), 1), 6) - 1) * 600 / 6) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 600, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 6 / 600), 1), 6) * 600 / 6) AS __bucket_max, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE mapContains(attributes_number, 'http.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, __bucket_min, __bucket_max",
|
||||
Args: []any{"1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600), 5, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "QualifiedField_OrderedByAggregation_OrdersByCount",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "attribute.amount"}},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
Limit: 3,
|
||||
Order: []qbtypes.OrderBy{{
|
||||
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "amount", FieldContext: telemetrytypes.FieldContextAttribute}},
|
||||
Direction: qbtypes.OrderDirectionAsc,
|
||||
}},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH ifNull(multiIf(mapContains(attributes_number, 'amount'), toFloat64(attributes_number['amount']), NULL), 0) AS __bucket_value, __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE mapContains(attributes_number, 'amount') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 asc LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE mapContains(attributes_number, 'amount') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, __bucket_min, __bucket_max ORDER BY ts desc",
|
||||
Args: []any{"1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600), 3, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IntrinsicColumn_NoExists",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "log.severity_number"}},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH ifNull(toFloat64(severity_number), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, __bucket_min, __bucket_max",
|
||||
Args: []any{"1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Distribution_AttributeField_SameStatementAsHeatmap",
|
||||
requestType: qbtypes.RequestTypeDistribution,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "duration"}},
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'cartservice'"},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'duration'), toFloat64(attributes_number['duration']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'duration') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, __bucket_min, __bucket_max",
|
||||
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705397400), uint64(1705485600), "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
fl := flaggertest.New(t)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
storage := logstelemetryschema.NewStorage()
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
storage,
|
||||
aggExprRewriter,
|
||||
logstelemetryschema.DefaultFullTextColumn,
|
||||
fl,
|
||||
nil,
|
||||
statementbuilder.Config{SkipResourceFingerprint: statementbuilder.SkipResourceFingerprint{Enabled: false, Threshold: 100000}},
|
||||
)
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
statement, err := statementBuilder.Build(context.Background(), valuer.UUID{}, startTs, endTs, testCase.requestType, testCase.query, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expected.Query, statement.Query)
|
||||
assert.Equal(t, testCase.expected.Args, statement.Args)
|
||||
assert.Equal(t, testCase.expected.Warnings, statement.Warnings)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/clickhousesql"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
@@ -917,6 +914,10 @@ const (
|
||||
|
||||
heatmapValueAlias = "__result_0"
|
||||
heatmapWindow = "__heatmap_window"
|
||||
|
||||
// The column __spatial_aggregation_cte carries the sample in, which is what
|
||||
// the bucket axis is cut from.
|
||||
heatmapValueColumn = "value"
|
||||
)
|
||||
|
||||
func isHistogramBucket(k qbtypes.GroupByKey) bool { return k.Name == histogramBucketKey }
|
||||
@@ -982,10 +983,7 @@ func buildValueHeatmapFinalSelect(
|
||||
args []any,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
bucketMin, bucketMax, err := renderHeatmapBucketExprs(*query.Aggregations[0].HeatmapBucketing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bucketMin, bucketMax := querybuilder.RenderHeatmapBucketExprs(*query.Aggregations[0].HeatmapBucketing, heatmapValueColumn)
|
||||
|
||||
groupAliases := GroupByAliases(query.GroupBy)
|
||||
|
||||
@@ -1003,61 +1001,6 @@ func buildValueHeatmapFinalSelect(
|
||||
return &qbtypes.Statement{Query: combined + q, Args: append(args, a...)}, nil
|
||||
}
|
||||
|
||||
// renderHeatmapBucketExprs renders the bucket (min, max] that `value` falls in.
|
||||
// Only the bucket under everything the axis covers is open below, and only the
|
||||
// one over it is open above.
|
||||
func renderHeatmapBucketExprs(bucketing qbtypes.HeatmapBucketing) (minExpr, maxExpr string, err error) {
|
||||
switch bucketing.Kind {
|
||||
case qbtypes.BucketsKindLinear:
|
||||
return renderLinearBucketExprs(bucketing)
|
||||
case qbtypes.BucketsKindLog:
|
||||
return renderLogBucketExprs()
|
||||
default:
|
||||
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"unsupported bucketsScaling %q for heatmap requests", bucketing.Kind.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
func renderLinearBucketExprs(bucketing qbtypes.HeatmapBucketing) (string, string, error) {
|
||||
maxValue := formatFloat(bucketing.MaxValue)
|
||||
numBuckets := strconv.Itoa(bucketing.NumBuckets)
|
||||
index := fmt.Sprintf("least(greatest(ceil(value * %s / %s), 1), %s)", numBuckets, maxValue, numBuckets)
|
||||
|
||||
minExpr := fmt.Sprintf(
|
||||
"multiIf(value <= 0, toFloat64('-Inf'), value > %s, toFloat64(%s), (%s - 1) * %s / %s)",
|
||||
maxValue, maxValue, index, maxValue, numBuckets,
|
||||
)
|
||||
maxExpr := fmt.Sprintf(
|
||||
"multiIf(value <= 0, toFloat64(0), value > %s, toFloat64('+Inf'), %s * %s / %s)",
|
||||
maxValue, index, maxValue, numBuckets,
|
||||
)
|
||||
return minExpr, maxExpr, nil
|
||||
}
|
||||
|
||||
// ClickHouse buckets at MaxLogScale whatever HeatmapBucketing.LogScale asks for;
|
||||
// postprocessing folds the axis down afterwards.
|
||||
func renderLogBucketExprs() (string, string, error) {
|
||||
bucketsPerDoubling := formatFloat(math.Exp2(qbtypes.MaxLogScale))
|
||||
lowest := formatFloat(qbtypes.MinLogUpperBound)
|
||||
highest := formatFloat(qbtypes.MaxLogUpperBound)
|
||||
|
||||
minExpr := fmt.Sprintf(
|
||||
"multiIf(value <= 0, toFloat64('-Inf'), value <= %s, toFloat64(0), value > %s, toFloat64(%s), pow(2, (ceil(log2(value) * %s) - 1) / %s))",
|
||||
lowest, highest, highest, bucketsPerDoubling, bucketsPerDoubling,
|
||||
)
|
||||
maxExpr := fmt.Sprintf(
|
||||
"multiIf(value <= 0, toFloat64(0), value <= %s, %s, value > %s, toFloat64('+Inf'), pow(2, ceil(log2(value) * %s) / %s))",
|
||||
lowest, lowest, highest, bucketsPerDoubling, bucketsPerDoubling,
|
||||
)
|
||||
return minExpr, maxExpr, nil
|
||||
}
|
||||
|
||||
// formatFloat renders a float64 as the shortest literal that reads back as the
|
||||
// same value, so an upper bound computed from it is identical on every row.
|
||||
func formatFloat(v float64) string {
|
||||
return strconv.FormatFloat(v, 'g', -1, 64)
|
||||
}
|
||||
|
||||
func GroupByColumnAlias(i int, name string) string {
|
||||
if name == histogramBucketKey {
|
||||
return clickhousesql.Identifier(histogramBucketKey)
|
||||
|
||||
@@ -178,6 +178,8 @@ func (b *traceQueryStatementBuilder) Build(
|
||||
return b.buildListQuery(ctx, orgID, q, query, start, end, keys, variables, isSelectFieldsEmpty)
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
return b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeHeatmap, qbtypes.RequestTypeDistribution:
|
||||
return b.buildHeatmapTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
return b.buildScalarQuery(ctx, orgID, q, query, start, end, keys, variables, false, false)
|
||||
case qbtypes.RequestTypeTrace:
|
||||
@@ -570,6 +572,48 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, query, start, end, keys, variables, nil)
|
||||
}
|
||||
|
||||
// buildHeatmapTimeSeriesQuery counts rows per (time bucket, group, value
|
||||
// bucket) of the field its aggregation names.
|
||||
func (b *traceQueryStatementBuilder) buildHeatmapTimeSeriesQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
heatmapField, countQuery, err := qbtypes.HeatmapCountQuery(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
|
||||
mayBeAbsent, err := querybuilder.HeatmapFieldMayBeAbsent(ctx, info, b.storage, heatmapField, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mayBeAbsent {
|
||||
countQuery = qbtypes.RequireHeatmapField(countQuery, *heatmapField)
|
||||
}
|
||||
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, countQuery, start, end, keys, variables, heatmapField)
|
||||
}
|
||||
|
||||
// buildTimeSeriesOrHeatmapQuery builds one row per (time bucket, group). A
|
||||
// non-nil heatmapField makes it a heatmap instead: the bucket a row's
|
||||
// heatmapField falls in joins the group keys, so a row is one cell.
|
||||
func (b *traceQueryStatementBuilder) buildTimeSeriesOrHeatmapQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
heatmapField *telemetrytypes.TelemetryFieldKey,
|
||||
) (*qbtypes.Statement, error) {
|
||||
|
||||
var (
|
||||
cteFragments []string
|
||||
@@ -610,6 +654,19 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
fieldNames = append(fieldNames, sqlbuilder.Escape(fieldAlias))
|
||||
}
|
||||
|
||||
var bucketAliases []string
|
||||
if heatmapField != nil {
|
||||
valueFragment, aliases, warnings, err := querybuilder.HeatmapBucketColumns(
|
||||
ctx, info, b.storage, sb, heatmapField, query.BucketOptions, keys,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cteFragments = append(cteFragments, valueFragment)
|
||||
bucketAliases = aliases
|
||||
columnWarnings = append(columnWarnings, warnings...)
|
||||
}
|
||||
|
||||
// Aggregations
|
||||
allAggChArgs := make([]any, 0)
|
||||
for i, agg := range query.Aggregations {
|
||||
@@ -653,6 +710,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
// Group by all dimensions
|
||||
sb.GroupBy("ts")
|
||||
sb.GroupBy(fieldNames...)
|
||||
sb.GroupBy(bucketAliases...)
|
||||
if query.Having != nil && query.Having.Expression != "" {
|
||||
rewriter := querybuilder.NewHavingExpressionRewriter()
|
||||
rewrittenExpr, err := rewriter.RewriteForTraces(query.Having.Expression, query.Aggregations)
|
||||
@@ -686,6 +744,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
} else {
|
||||
sb.GroupBy("ts")
|
||||
sb.GroupBy(fieldNames...)
|
||||
sb.GroupBy(bucketAliases...)
|
||||
if query.Having != nil && query.Having.Expression != "" {
|
||||
rewriter := querybuilder.NewHavingExpressionRewriter()
|
||||
rewrittenExpr, err := rewriter.RewriteForTraces(query.Having.Expression, query.Aggregations)
|
||||
|
||||
@@ -2113,3 +2113,103 @@ func TestStatementBuilderSemconvSingleSpelling(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'deployment.environment') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT toString(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL)) AS `__GROUP_BY_KEY_0_deployment.environment.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY __result_0 DESC", q.Query)
|
||||
}
|
||||
|
||||
func TestStatementBuilderHeatmap(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
|
||||
expected qbtypes.Statement
|
||||
}{
|
||||
{
|
||||
name: "DurationNano_CollidingAttribute_NoExists",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "duration_nano"}},
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'redis-manual'"},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(toFloat64(duration_nano), 0) AS __bucket_value SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY ts, __bucket_min, __bucket_max",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AttributeField_GroupByWithLimit_LinearAxis_RequiresFieldInLimit",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "cart.items_count"}},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
Limit: 5,
|
||||
BucketOptions: &qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLinear, Spec: qbtypes.LinearBucketsSpec{MaxValue: 50, NumBuckets: 5}},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH ifNull(multiIf(`attribute_number_cart$$items_count_exists` = true, toFloat64(`attribute_number_cart$$items_count`), NULL), 0) AS __bucket_value, __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE `attribute_number_cart$$items_count_exists` = true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 50, toFloat64(50), (least(greatest(ceil(__bucket_value * 5 / 50), 1), 5) - 1) * 50 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 50, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 50), 1), 5) * 50 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE `attribute_number_cart$$items_count_exists` = true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, __bucket_min, __bucket_max",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 5, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "QualifiedField_OrderedByAggregation_OrdersByCount",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "attribute.metric.max_count"}},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
Limit: 3,
|
||||
Order: []qbtypes.OrderBy{{
|
||||
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "metric.max_count", FieldContext: telemetrytypes.FieldContextAttribute}},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
}},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH ifNull(multiIf(mapContains(attributes_number, 'metric.max_count'), toFloat64(attributes_number['metric.max_count']), NULL), 0) AS __bucket_value, __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE mapContains(attributes_number, 'metric.max_count') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 desc LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE mapContains(attributes_number, 'metric.max_count') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, __bucket_min, __bucket_max ORDER BY ts desc",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 3, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Distribution_DurationNano_SameStatementAsHeatmap",
|
||||
requestType: qbtypes.RequestTypeDistribution,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "duration_nano"}},
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'redis-manual'"},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(toFloat64(duration_nano), 0) AS __bucket_value SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY ts, __bucket_min, __bucket_max",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
releaseTime := time.Date(2025, 5, 22, 0, 0, 0, 0, time.UTC)
|
||||
fl := flaggertest.New(t)
|
||||
storage := tracestelemetryschema.NewStorage(nil)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
storage,
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
statement, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, testCase.requestType, testCase.query, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expected.Query, statement.Query)
|
||||
assert.Equal(t, testCase.expected.Args, statement.Args)
|
||||
assert.Equal(t, testCase.expected.Warnings, statement.Warnings)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,216 +669,6 @@ func TestValidateHeatmapDashboard(t *testing.T) {
|
||||
assert.Equal(t, request, rereadRequest)
|
||||
}
|
||||
|
||||
func TestValidateDistributionDashboard(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/perses_distribution_panel.json")
|
||||
require.NoError(t, err, "reading example file")
|
||||
|
||||
spec, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &DistributionPanelSpec{}, spec.Panels["p1"].Spec.Plugin.Spec)
|
||||
panelSpec := spec.Panels["p1"].Spec.Plugin.Spec.(*DistributionPanelSpec)
|
||||
require.NotNil(t, panelSpec.Axes.X.Min)
|
||||
assert.Equal(t, 10.0, *panelSpec.Axes.X.Min)
|
||||
assert.Nil(t, panelSpec.Axes.X.Max)
|
||||
assert.Equal(t, "log", panelSpec.Axes.Y.Scale.ValueOrDefault())
|
||||
assert.Equal(t, "none", panelSpec.ChartAppearance.Stack.ValueOrDefault())
|
||||
assert.True(t, panelSpec.ChartAppearance.Percentiles.Enabled)
|
||||
assert.Equal(t, []float64{75, 99.9}, panelSpec.ChartAppearance.Percentiles.Values)
|
||||
|
||||
dashboard := &DashboardV2{Spec: *spec}
|
||||
request, err := dashboard.GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the panel's query failed")
|
||||
assert.Equal(t, qb.RequestTypeDistribution, request.RequestType)
|
||||
require.NoError(t, request.Validate(), "the request built from the panel is not a valid distribution request")
|
||||
|
||||
stored, err := json.Marshal(spec)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
reread, err := unmarshalDashboard(stored)
|
||||
require.NoError(t, err, "the stored dashboard does not validate")
|
||||
assert.Equal(t, panelSpec, reread.Panels["p1"].Spec.Plugin.Spec)
|
||||
}
|
||||
|
||||
func TestDistributionPanelSpecDefaults(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
}{
|
||||
{name: "EmptySpec", spec: `{}`},
|
||||
{name: "EmptyChartAppearance", spec: `{"chartAppearance": {}}`},
|
||||
{name: "EmptyPercentiles", spec: `{"chartAppearance": {"percentiles": {}}}`},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var plugin PanelPlugin
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"kind": "signoz/DistributionPanel", "spec": `+testCase.spec+`}`), &plugin))
|
||||
|
||||
spec := plugin.Spec.(*DistributionPanelSpec)
|
||||
assert.Nil(t, spec.Axes.X.Min)
|
||||
assert.Nil(t, spec.Axes.X.Max)
|
||||
assert.Equal(t, "linear", spec.Axes.Y.Scale.ValueOrDefault())
|
||||
assert.Equal(t, "normal", spec.ChartAppearance.Stack.ValueOrDefault())
|
||||
assert.True(t, spec.ChartAppearance.Percentiles.Enabled)
|
||||
assert.Equal(t, []float64{50, 90, 99}, spec.ChartAppearance.Percentiles.Values)
|
||||
|
||||
stored, err := json.Marshal(spec)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(stored), `"stack":"normal"`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionPanelSpecPercentileValues(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
expectedEnabled bool
|
||||
expectedValues []float64
|
||||
}{
|
||||
{name: "Disabled_KeepsDefaultValues", spec: `{"chartAppearance": {"percentiles": {"enabled": false}}}`, expectedEnabled: false, expectedValues: []float64{50, 90, 99}},
|
||||
{name: "EmptyValues_MarksNone", spec: `{"chartAppearance": {"percentiles": {"values": []}}}`, expectedEnabled: true, expectedValues: []float64{}},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var plugin PanelPlugin
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"kind": "signoz/DistributionPanel", "spec": `+testCase.spec+`}`), &plugin))
|
||||
|
||||
spec := plugin.Spec.(*DistributionPanelSpec)
|
||||
assert.Equal(t, testCase.expectedEnabled, spec.ChartAppearance.Percentiles.Enabled)
|
||||
assert.Equal(t, testCase.expectedValues, spec.ChartAppearance.Percentiles.Values)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionPanelSpecXMinEqualsMax(t *testing.T) {
|
||||
var plugin PanelPlugin
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"kind": "signoz/DistributionPanel", "spec": {"axes": {"x": {"min": 10, "max": 10}}}}`), &plugin))
|
||||
|
||||
spec := plugin.Spec.(*DistributionPanelSpec)
|
||||
require.NotNil(t, spec.Axes.X.Min)
|
||||
require.NotNil(t, spec.Axes.X.Max)
|
||||
assert.Equal(t, 10.0, *spec.Axes.X.Min)
|
||||
assert.Equal(t, 10.0, *spec.Axes.X.Max)
|
||||
}
|
||||
|
||||
func TestDistributionPanelSpecRejections(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
expectedMessage string
|
||||
}{
|
||||
{
|
||||
name: "XMin_AboveMax",
|
||||
spec: `{"axes": {"x": {"min": 20, "max": 10}}}`,
|
||||
expectedMessage: "axes.x.min 20 is greater than axes.x.max 10",
|
||||
},
|
||||
{
|
||||
name: "YScale_Unknown",
|
||||
spec: `{"axes": {"y": {"scale": "symlog"}}}`,
|
||||
expectedMessage: "invalid distribution y scale",
|
||||
},
|
||||
{
|
||||
name: "UnknownField",
|
||||
spec: `{"axes": {"y": {"softMax": 10}}}`,
|
||||
expectedMessage: "unknown field \"softMax\"",
|
||||
},
|
||||
{
|
||||
name: "Percentile_Zero",
|
||||
spec: `{"chartAppearance": {"percentiles": {"values": [0, 50]}}}`,
|
||||
expectedMessage: "0 is not in (0, 100)",
|
||||
},
|
||||
{
|
||||
name: "Percentile_Hundred",
|
||||
spec: `{"chartAppearance": {"percentiles": {"values": [100]}}}`,
|
||||
expectedMessage: "100 is not in (0, 100)",
|
||||
},
|
||||
{
|
||||
name: "Percentile_Duplicate",
|
||||
spec: `{"chartAppearance": {"percentiles": {"values": [90, 90]}}}`,
|
||||
expectedMessage: "90 is listed more than once",
|
||||
},
|
||||
{
|
||||
name: "Percentiles_MoreThanFive",
|
||||
spec: `{"chartAppearance": {"percentiles": {"values": [10, 25, 50, 75, 90, 99]}}}`,
|
||||
expectedMessage: "at most 5 percentiles",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var plugin PanelPlugin
|
||||
err := json.Unmarshal([]byte(`{"kind": "signoz/DistributionPanel", "spec": `+testCase.spec+`}`), &plugin)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), testCase.expectedMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionPanelQueryKinds(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
queryPluginKind string
|
||||
queryPluginSpec string
|
||||
expectedAllowed bool
|
||||
}{
|
||||
{
|
||||
name: "PromQL_Allowed",
|
||||
queryPluginKind: "signoz/PromQLQuery",
|
||||
queryPluginSpec: `{"name": "A", "query": "sum by (le) (increase(signoz_latency_bucket[5m]))"}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "ClickHouseSQL_Allowed",
|
||||
queryPluginKind: "signoz/ClickHouseSQL",
|
||||
queryPluginSpec: `{"name": "A", "query": "SELECT ts, bucket, value FROM cells"}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "TraceOperator_Refused",
|
||||
queryPluginKind: "signoz/TraceOperator",
|
||||
queryPluginSpec: `{"name": "T1", "expression": "A => B"}`,
|
||||
expectedAllowed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
data := fmt.Sprintf(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/DistributionPanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "distribution",
|
||||
"spec": {
|
||||
"plugin": {"kind": %q, "spec": %s}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`, testCase.queryPluginKind, testCase.queryPluginSpec)
|
||||
|
||||
_, err := unmarshalDashboard([]byte(data))
|
||||
|
||||
if testCase.expectedAllowed {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "is not supported by panel kind")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateOneInvalidPanel(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
|
||||
@@ -28,18 +28,17 @@ type PanelPlugin struct {
|
||||
// (and strips the duplicate parent properties) after reflection.
|
||||
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
|
||||
string(PanelKindDistribution): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec"),
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -74,7 +73,6 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
|
||||
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
|
||||
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
|
||||
PanelPluginVariant[DistributionPanelSpec]{Kind: string(PanelKindDistribution)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,18 +236,17 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
|
||||
|
||||
var (
|
||||
panelPluginSpecs = map[PanelPluginKind]func() any{
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
|
||||
PanelKindDistribution: func() any { return new(DistributionPanelSpec) },
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -266,18 +263,17 @@ var (
|
||||
VariableKindCustom: func() any { return new(CustomVariableSpec) },
|
||||
}
|
||||
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindClickHouseSQL},
|
||||
PanelKindDistribution: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindClickHouseSQL},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ func (k *PanelKind) UnmarshalJSON(data []byte) error {
|
||||
|
||||
type PanelSpec struct {
|
||||
Display Display `json:"display" required:"true" description:"The panel title and description."`
|
||||
Plugin PanelPlugin `json:"plugin" required:"true" description:"The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel, signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind's options ({} accepts every default)."`
|
||||
Plugin PanelPlugin `json:"plugin" required:"true" description:"The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel or signoz/HeatmapPanel, and spec holds that kind's options ({} accepts every default)."`
|
||||
Queries []Query `json:"queries" required:"true" nullable:"false" description:"The queries the panel runs. Use exactly one entry for a data panel and [] for signoz/TextPanel. To run several named queries or formulas, use one entry whose spec.plugin.kind is signoz/CompositeQuery and put the constituent queries in its plugin spec."`
|
||||
Links []Link `json:"links,omitzero" description:"Context links offered in the data point drilldown menu of the panel, with dashboard variables resolved in the URL."`
|
||||
}
|
||||
@@ -92,13 +92,13 @@ type Link struct {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type Query struct {
|
||||
Kind qb.RequestType `json:"kind" required:"true" description:"The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, distribution for distribution panels, trace for trace panels."`
|
||||
Kind qb.RequestType `json:"kind" required:"true" description:"The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, trace for trace panels."`
|
||||
Spec QuerySpec `json:"spec" required:"true" description:"The query content: an optional name and the query plugin."`
|
||||
}
|
||||
|
||||
type QuerySpec struct {
|
||||
Name string `json:"name,omitempty" description:"An optional label for the query. The name queries refer to each other by lives in the plugin spec."`
|
||||
Plugin QueryPlugin `json:"plugin" required:"true" description:"The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap and distribution panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery."`
|
||||
Plugin QueryPlugin `json:"plugin" required:"true" description:"The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery."`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
@@ -189,22 +189,21 @@ func (AIBuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
type PanelPluginKind string
|
||||
|
||||
const (
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
|
||||
PanelKindDistribution PanelPluginKind = "signoz/DistributionPanel"
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot, PanelKindDistribution}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
@@ -347,89 +346,6 @@ func (c HeatmapColors) validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type DistributionPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization" description:"The time range the bucket counts are summed over."`
|
||||
Formatting PanelFormatting `json:"formatting" description:"Unit and decimal precision of the bucket bounds; counts take no unit."`
|
||||
Axes DistributionAxes `json:"axes" description:"The X (bucket) axis range and Y (count) axis scale."`
|
||||
Legend Legend `json:"legend" description:"Legend placement and series colors."`
|
||||
ChartAppearance DistributionChartAppearance `json:"chartAppearance" description:"Stacking and percentile markers."`
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes over the defaults, since the stack default (normal)
|
||||
// differs from StackMode's own and percentile markers default to on.
|
||||
func (spec *DistributionPanelSpec) UnmarshalJSON(data []byte) error {
|
||||
type alias DistributionPanelSpec
|
||||
tmp := alias{ChartAppearance: DistributionChartAppearance{
|
||||
Stack: StackModeNormal,
|
||||
Percentiles: DistributionPercentiles{Enabled: true, Values: []float64{50, 90, 99}},
|
||||
}}
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
*spec = DistributionPanelSpec(tmp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (spec *DistributionPanelSpec) validate() error {
|
||||
if err := spec.Axes.X.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return spec.ChartAppearance.Percentiles.validate()
|
||||
}
|
||||
|
||||
type DistributionAxes struct {
|
||||
X DistributionXAxis `json:"x" description:"The bucket axis."`
|
||||
Y DistributionYAxis `json:"y" description:"The count axis."`
|
||||
}
|
||||
|
||||
// DistributionXAxis draws every bucket equally wide, whatever its bounds.
|
||||
type DistributionXAxis struct {
|
||||
Min *float64 `json:"min" description:"Buckets wholly below this value are hidden. Null shows them all."`
|
||||
Max *float64 `json:"max" description:"Buckets wholly above this value are hidden. Null shows them all."`
|
||||
}
|
||||
|
||||
func (axis DistributionXAxis) validate() error {
|
||||
if axis.Min != nil && axis.Max != nil && *axis.Min > *axis.Max {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "axes.x.min %v is greater than axes.x.max %v", *axis.Min, *axis.Max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type DistributionYAxis struct {
|
||||
Scale DistributionYScale `json:"scale" required:"true" description:"How counts are spaced: linear (default) or log."`
|
||||
}
|
||||
|
||||
type DistributionChartAppearance struct {
|
||||
Stack StackMode `json:"stack" required:"true" description:"How groups combine in a bucket: normal (default) stacks them, none draws them side by side, percent stacks them as shares of the bucket."`
|
||||
Percentiles DistributionPercentiles `json:"percentiles" description:"Percentile markers drawn across the bucket axis."`
|
||||
}
|
||||
|
||||
const maxDistributionPercentiles = 5
|
||||
|
||||
type DistributionPercentiles struct {
|
||||
Enabled bool `json:"enabled" required:"true" description:"Whether percentile markers are drawn. Defaults to true."`
|
||||
Values []float64 `json:"values" required:"true" description:"The percentiles to mark, each in (0, 100), at most 5, no duplicates. Defaults to [50, 90, 99]."`
|
||||
}
|
||||
|
||||
func (percentiles DistributionPercentiles) validate() error {
|
||||
if len(percentiles.Values) > maxDistributionPercentiles {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "chartAppearance.percentiles.values: at most %d percentiles, got %d", maxDistributionPercentiles, len(percentiles.Values))
|
||||
}
|
||||
seen := make(map[float64]struct{}, len(percentiles.Values))
|
||||
for _, value := range percentiles.Values {
|
||||
if value <= 0 || value >= 100 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "chartAppearance.percentiles.values: %v is not in (0, 100)", value)
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "chartAppearance.percentiles.values: %v is listed more than once", value)
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TextPanelSpec struct {
|
||||
Mode TextMode `json:"mode" description:"How text is interpreted. Only markdown is supported."`
|
||||
Text string `json:"text" description:"The content to render."`
|
||||
@@ -1546,43 +1462,6 @@ func (s *HeatmapYScale) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
type DistributionYScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
DistributionYScaleLinear = DistributionYScale{valuer.NewString("linear")} // default
|
||||
DistributionYScaleLog = DistributionYScale{valuer.NewString("log")}
|
||||
)
|
||||
|
||||
func (DistributionYScale) Enum() []any {
|
||||
return []any{DistributionYScaleLinear, DistributionYScaleLog}
|
||||
}
|
||||
|
||||
func (scale DistributionYScale) ValueOrDefault() string {
|
||||
if scale.IsZero() {
|
||||
return DistributionYScaleLinear.StringValue()
|
||||
}
|
||||
return scale.StringValue()
|
||||
}
|
||||
|
||||
func (scale DistributionYScale) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(scale.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (scale *DistributionYScale) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid distribution y scale: must be a string, one of `linear` or `log`")
|
||||
}
|
||||
parsed := DistributionYScale{valuer.NewString(v)}
|
||||
switch parsed {
|
||||
case DistributionYScaleLinear, DistributionYScaleLog:
|
||||
*scale = parsed
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid distribution y scale %q: must be `linear` or `log`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type HeatmapColorScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
{
|
||||
"display": {
|
||||
"name": "latency",
|
||||
"description": "how request duration is distributed over the range"
|
||||
},
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "request duration",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/DistributionPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time"
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "s",
|
||||
"decimalPrecision": "3"
|
||||
},
|
||||
"axes": {
|
||||
"x": {
|
||||
"min": 10
|
||||
},
|
||||
"y": {
|
||||
"scale": "log"
|
||||
}
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "list"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"stack": "none",
|
||||
"percentiles": {
|
||||
"enabled": true,
|
||||
"values": [75, 99.9]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"queries": [
|
||||
{
|
||||
"kind": "distribution",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "http.server.request.duration",
|
||||
"timeAggregation": "increase",
|
||||
"spaceAggregation": "sum"
|
||||
}
|
||||
],
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 12,
|
||||
"height": 8,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/p1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -28,7 +28,7 @@ type QueryBuilderFormula struct {
|
||||
|
||||
Offset int `json:"offset,omitempty" description:"The number of ordered series or rows to skip before the page starts. Requires limit."`
|
||||
|
||||
Having *Having `json:"having,omitempty" description:"Accepted but currently ignored; the computed values are returned unfiltered."`
|
||||
Having *Having `json:"having,omitempty" description:"A filter on the formula values, referencing the formula by its name or as __result, for example F1 > 0. A time series keeps only the points that match and drops a series with none left; a scalar result keeps only the rows that match."`
|
||||
|
||||
Functions []Function `json:"functions,omitzero" description:"Post-processing functions applied in order to each result series."`
|
||||
|
||||
@@ -118,6 +118,10 @@ func (f QueryBuilderFormula) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
if f.Limit < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be non-negative, got %d", f.Limit)
|
||||
}
|
||||
|
||||
// Validate functions if present
|
||||
for i, fn := range f.Functions {
|
||||
if err := fn.Validate(); err != nil {
|
||||
|
||||
@@ -3,9 +3,11 @@ package querybuildertypesv5
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -16,6 +18,9 @@ const (
|
||||
HeatmapBucketMinColumn = "__bucket_min"
|
||||
HeatmapBucketMaxColumn = "__bucket_max"
|
||||
|
||||
// A logs or traces heatmap counts events, so its field renders as count().
|
||||
heatmapCountExpression = "count()"
|
||||
|
||||
DefaultNumBuckets = 60
|
||||
|
||||
// MaxLogScale is the resolution ClickHouse buckets every log heatmap at:
|
||||
@@ -81,6 +86,68 @@ func (b *BucketOptions) ToHeatmapBucketing() HeatmapBucketing {
|
||||
return resolved
|
||||
}
|
||||
|
||||
// HeatmapAggregationField returns the field a logs or traces heatmap buckets,
|
||||
// written as the bare field. ok is false for any other aggregation.
|
||||
func HeatmapAggregationField(expression string) (telemetrytypes.TelemetryFieldKey, bool) {
|
||||
keyText := strings.TrimSpace(expression)
|
||||
if keyText == "" || strings.ContainsAny(keyText, "() \t\n") {
|
||||
return telemetrytypes.TelemetryFieldKey{}, false
|
||||
}
|
||||
return telemetrytypes.GetFieldKeyFromKeyText(keyText), true
|
||||
}
|
||||
|
||||
// HeatmapCountQuery splits a logs or traces heatmap query into the field its
|
||||
// aggregation buckets and a copy of query that counts events in its place,
|
||||
// which is what every cell holds.
|
||||
func HeatmapCountQuery[T LogAggregation | TraceAggregation](query QueryBuilderQuery[T]) (*telemetrytypes.TelemetryFieldKey, QueryBuilderQuery[T], error) {
|
||||
if len(query.Aggregations) != 1 {
|
||||
return nil, query, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"a heatmap renders one bucket axis, but the query has %d aggregations", len(query.Aggregations))
|
||||
}
|
||||
|
||||
query.Aggregations = slices.Clone(query.Aggregations)
|
||||
var expression *string
|
||||
switch aggregation := any(&query.Aggregations[0]).(type) {
|
||||
case *LogAggregation:
|
||||
expression = &aggregation.Expression
|
||||
case *TraceAggregation:
|
||||
expression = &aggregation.Expression
|
||||
}
|
||||
|
||||
field, ok := HeatmapAggregationField(*expression)
|
||||
if !ok {
|
||||
return nil, query, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"a logs or traces heatmap counts events by the field named as its aggregation, got %q", *expression)
|
||||
}
|
||||
|
||||
// An order on the aggregation names it by its expression, which decoding
|
||||
// splits into a context, a name and a data type, and which metadata may
|
||||
// complete, as it does any field.
|
||||
query.Order = slices.Clone(query.Order)
|
||||
for i := range query.Order {
|
||||
key := query.Order[i].Key.TelemetryFieldKey
|
||||
sameField := key.Name == field.Name &&
|
||||
(field.FieldContext == telemetrytypes.FieldContextUnspecified || key.FieldContext == field.FieldContext) &&
|
||||
(field.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || key.FieldDataType == field.FieldDataType)
|
||||
if key.Name == *expression || sameField {
|
||||
query.Order[i].Key = OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: heatmapCountExpression}}
|
||||
}
|
||||
}
|
||||
*expression = heatmapCountExpression
|
||||
|
||||
return &field, query, nil
|
||||
}
|
||||
|
||||
// RequireHeatmapField keeps only the rows of query that carry field.
|
||||
func RequireHeatmapField[T LogAggregation | TraceAggregation](query QueryBuilderQuery[T], field telemetrytypes.TelemetryFieldKey) QueryBuilderQuery[T] {
|
||||
exists := telemetrytypes.TelemetryFieldKeyToText(&field) + " EXISTS"
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
exists = "(" + query.Filter.Expression + ") AND " + exists
|
||||
}
|
||||
query.Filter = &Filter{Expression: exists}
|
||||
return query
|
||||
}
|
||||
|
||||
// This cannot be called in validateBucketCounting cuz metric type is resolved in querier.go.
|
||||
func (a *MetricAggregation) VerifyAndApplyBucketOptions(bucketOptions *BucketOptions) error {
|
||||
switch a.Type {
|
||||
|
||||
@@ -23,7 +23,8 @@ func (r *RequestType) UnmarshalJSON(data []byte) error {
|
||||
*r = v
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q; allowed values: %s", s, "`scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `distribution`, `heatmap`")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q", s).
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -499,6 +499,18 @@ func (q *QueryBuilderQuery[T]) validateOrderBy(cfg validationConfig) error {
|
||||
return q.validateOrderByForAggregation()
|
||||
}
|
||||
|
||||
// a raw order key resolves like a select field, so only an empty one is
|
||||
// invalid before resolution
|
||||
for i, order := range q.Order {
|
||||
if order.Key.Name == "" {
|
||||
orderID := fmt.Sprintf("order by clause #%d", i+1)
|
||||
if q.Name != "" {
|
||||
orderID = fmt.Sprintf("order by clause #%d in query '%s'", i+1, q.Name)
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid empty key name for %s", orderID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -586,19 +598,10 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate request type
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap, RequestTypeDistribution:
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
default:
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid request type: %s",
|
||||
r.RequestType,
|
||||
).WithAdditional(
|
||||
"Valid request types are: raw, timeseries, scalar, heatmap, distribution",
|
||||
)
|
||||
if err := r.validateRequestType(); err != nil {
|
||||
return err
|
||||
}
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
|
||||
if err := r.validateBucketCounting(); err != nil {
|
||||
return err
|
||||
@@ -639,14 +642,10 @@ func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var opts []ValidationOption
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap, RequestTypeDistribution:
|
||||
opts = GetValidationOptions(r.RequestType)
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
|
||||
WithAdditional("Valid request types are: raw, timeseries, scalar, heatmap, distribution")
|
||||
if err := r.validateRequestType(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := GetValidationOptions(r.RequestType)
|
||||
|
||||
if err := r.validateBucketCounting(); err != nil {
|
||||
return nil, err
|
||||
@@ -703,6 +702,20 @@ func (e QueryEnvelope) Validate(opts ...ValidationOption) error {
|
||||
return validateQueryEnvelope(e, opts...)
|
||||
}
|
||||
|
||||
// Enum is the one list of request types a query can use, so the spec and the
|
||||
// suggestions agree with what is accepted.
|
||||
func (r *QueryRangeRequest) validateRequestType() error {
|
||||
if slices.Contains(RequestType{}.Enum(), any(r.RequestType)) {
|
||||
return nil
|
||||
}
|
||||
if r.RequestType == RequestTypeUnknown {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "requestType is required").
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request type %q is not supported", r.RequestType.StringValue()).
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
}
|
||||
|
||||
func (r *QueryRangeRequest) validateTimeRange() error {
|
||||
if r.RequestType == RequestTypeRawStream {
|
||||
return nil
|
||||
@@ -921,15 +934,26 @@ func (r *QueryRangeRequest) validateBucketCounting() error {
|
||||
continue
|
||||
}
|
||||
enabled++
|
||||
// An AI query decodes to the traces spec, so it lands here too. Admitting
|
||||
// either signal means capping Aggregations at one: each carries its own
|
||||
// Meta.Buckets, and a heatmap renders against a single bucket axis.
|
||||
case QueryBuilderQuery[LogAggregation], QueryBuilderQuery[TraceAggregation]:
|
||||
return errors.New(errors.TypeUnsupported, errors.CodeUnsupported,
|
||||
"heatmaps are not supported for the logs and traces signals yet")
|
||||
// An AI query decodes to the traces spec, so it lands here too.
|
||||
case QueryBuilderQuery[LogAggregation]:
|
||||
if err := validateEventHeatmapQuery(r.RequestType, spec.Functions, spec.Having, aggregationExpressions(spec.Aggregations), spec.Disabled); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.Disabled {
|
||||
continue
|
||||
}
|
||||
enabled++
|
||||
case QueryBuilderQuery[TraceAggregation]:
|
||||
if err := validateEventHeatmapQuery(r.RequestType, spec.Functions, spec.Having, aggregationExpressions(spec.Aggregations), spec.Disabled); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.Disabled {
|
||||
continue
|
||||
}
|
||||
enabled++
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"%s requests support one metrics builder query, one formula over them, one clickhouse query, or one promql query, got %q", r.RequestType.StringValue(), envelope.Type.StringValue())
|
||||
"%s requests support one builder query, one formula over them, one clickhouse query, or one promql query, got %q", r.RequestType.StringValue(), envelope.Type.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,6 +1057,48 @@ func validateBucketCountingQuery(requestType RequestType, functions []Function,
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateEventHeatmapQuery holds a logs or traces query to what a per-event
|
||||
// heatmap can draw. A disabled query feeds a formula rather than the chart, so
|
||||
// it aggregates as a time series.
|
||||
func validateEventHeatmapQuery(requestType RequestType, functions []Function, having *Having, expressions []string, disabled bool) error {
|
||||
if err := validateBucketCountingQuery(requestType, functions, having); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if disabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Every aggregation carries its own Meta.Buckets, and a heatmap renders
|
||||
// against a single bucket axis.
|
||||
if len(expressions) != 1 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"a heatmap renders one bucket axis, but the query has %d aggregations", len(expressions)).
|
||||
WithAdditional("Keep the one aggregation whose distribution you want to plot")
|
||||
}
|
||||
|
||||
if _, ok := HeatmapAggregationField(expressions[0]); !ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"a logs or traces heatmap counts events by the field named as its aggregation, got %q", expressions[0]).
|
||||
WithAdditional("Use an aggregation such as duration_nano")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func aggregationExpressions[T LogAggregation | TraceAggregation](aggregations []T) []string {
|
||||
expressions := make([]string, len(aggregations))
|
||||
for i, aggregation := range aggregations {
|
||||
switch a := any(aggregation).(type) {
|
||||
case LogAggregation:
|
||||
expressions[i] = a.Expression
|
||||
case TraceAggregation:
|
||||
expressions[i] = a.Expression
|
||||
}
|
||||
}
|
||||
return expressions
|
||||
}
|
||||
|
||||
func GetValidationOptions(requestType RequestType) []ValidationOption {
|
||||
switch requestType {
|
||||
case RequestTypeTimeSeries, RequestTypeHeatmap, RequestTypeDistribution:
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -521,6 +527,28 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
wantErr: true,
|
||||
errMsg: "expression cannot be blank",
|
||||
},
|
||||
{
|
||||
name: "Formula_NegativeLimit_Rejected",
|
||||
request: QueryRangeRequest{
|
||||
Start: 1640995200000,
|
||||
End: 1640998800000,
|
||||
RequestType: RequestTypeTimeSeries,
|
||||
CompositeQuery: CompositeQuery{
|
||||
Queries: []QueryEnvelope{
|
||||
{
|
||||
Type: QueryTypeFormula,
|
||||
Spec: QueryBuilderFormula{
|
||||
Name: "F1",
|
||||
Expression: "A * 2",
|
||||
Limit: -5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid formula 'F1': limit must be non-negative, got -5",
|
||||
},
|
||||
{
|
||||
name: "promql with empty query should return error",
|
||||
request: QueryRangeRequest{
|
||||
@@ -696,6 +724,31 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
wantErr: true,
|
||||
errMsg: "raw request type is not supported for metric queries",
|
||||
},
|
||||
{
|
||||
name: "Raw_EmptyOrderKey_Rejected",
|
||||
request: QueryRangeRequest{
|
||||
Start: 1640995200000,
|
||||
End: 1640998800000,
|
||||
RequestType: RequestTypeRaw,
|
||||
CompositeQuery: CompositeQuery{
|
||||
Queries: []QueryEnvelope{
|
||||
{
|
||||
Type: QueryTypeBuilder,
|
||||
Spec: QueryBuilderQuery[LogAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Order: []OrderBy{
|
||||
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "timestamp"}}, Direction: OrderDirectionDesc},
|
||||
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: ""}}, Direction: OrderDirectionAsc},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid query 'B': invalid empty key name for order by clause #2 in query 'B'",
|
||||
},
|
||||
{
|
||||
name: "timeseries request with group by timestamp should return error",
|
||||
request: QueryRangeRequest{
|
||||
@@ -1407,6 +1460,44 @@ func TestRequestType_IsAggregation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRangeRequest_ValidateRequestType(t *testing.T) {
|
||||
validRequestTypes := []string{"valid request types are `scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `heatmap`, `distribution`"}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestType string
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "Missing_Required",
|
||||
requestType: "",
|
||||
message: "requestType is required",
|
||||
},
|
||||
{
|
||||
name: "Timeseries_Unknown",
|
||||
requestType: `"requestType": "timeseries",`,
|
||||
message: `unknown request type "timeseries"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
body := fmt.Sprintf(`{"schemaVersion": "v1", "start": 1640995200000, "end": 1640998800000, %s "compositeQuery": {"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs"}}]}}`, testCase.requestType)
|
||||
|
||||
var request QueryRangeRequest
|
||||
err := json.Unmarshal([]byte(body), &request)
|
||||
if err == nil {
|
||||
err = request.Validate()
|
||||
}
|
||||
require.Error(t, err)
|
||||
|
||||
got := errors.AsJSON(err)
|
||||
assert.Equal(t, testCase.message, got.Message)
|
||||
assert.Equal(t, validRequestTypes, got.Suggestions)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonAggregationFieldsSkipped(t *testing.T) {
|
||||
// Fields that only apply to aggregation queries (groupBy, having, aggregations)
|
||||
// should be silently skipped for non-aggregation request types.
|
||||
|
||||
41
tests/fixtures/querier.py
vendored
41
tests/fixtures/querier.py
vendored
@@ -363,6 +363,7 @@ def build_formula_query(
|
||||
order: list[dict] | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
having_expression: str | None = None,
|
||||
) -> dict:
|
||||
spec: dict[str, Any] = {
|
||||
"name": name,
|
||||
@@ -379,6 +380,8 @@ def build_formula_query(
|
||||
spec["limit"] = limit
|
||||
if offset is not None:
|
||||
spec["offset"] = offset
|
||||
if having_expression:
|
||||
spec["having"] = {"expression": having_expression}
|
||||
return {"type": "builder_formula", "spec": spec}
|
||||
|
||||
|
||||
@@ -727,6 +730,44 @@ def build_scalar_query(
|
||||
return {"type": "builder_query", "spec": spec}
|
||||
|
||||
|
||||
def build_heatmap_query(
|
||||
name: str,
|
||||
signal: str,
|
||||
expression: str,
|
||||
*,
|
||||
source: str | None = None,
|
||||
group_by: list[dict] | None = None,
|
||||
order: list[dict] | None = None,
|
||||
filter_expression: str | None = None,
|
||||
bucket_options: dict | None = None,
|
||||
step_interval: int = DEFAULT_STEP_INTERVAL,
|
||||
) -> dict:
|
||||
"""Omitting bucket_options leaves the server's default log axis."""
|
||||
spec: dict[str, Any] = {
|
||||
"name": name,
|
||||
"signal": signal,
|
||||
"stepInterval": step_interval,
|
||||
"aggregations": [{"expression": expression}],
|
||||
}
|
||||
|
||||
if source:
|
||||
spec["source"] = source
|
||||
|
||||
if group_by:
|
||||
spec["groupBy"] = group_by
|
||||
|
||||
if order:
|
||||
spec["order"] = order
|
||||
|
||||
if filter_expression:
|
||||
spec["filter"] = {"expression": filter_expression}
|
||||
|
||||
if bucket_options is not None:
|
||||
spec["bucketOptions"] = bucket_options
|
||||
|
||||
return {"type": "builder_query", "spec": spec}
|
||||
|
||||
|
||||
def build_traces_scalar_query(
|
||||
aggregations: list[dict],
|
||||
*,
|
||||
|
||||
83
tests/integration/tests/auditquerier/02_heatmap.py
Normal file
83
tests/integration/tests/auditquerier/02_heatmap.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.audit import AuditLog
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querier import (
|
||||
RequestType,
|
||||
assert_distribution_sums_heatmap,
|
||||
build_group_by_field,
|
||||
build_heatmap_query,
|
||||
build_linear_bucket_options,
|
||||
get_all_series,
|
||||
get_buckets,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
|
||||
def test_audit_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_audit_logs: Callable[[list[AuditLog]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
changed_by_action = {"update": [3, 8], "delete": [25]}
|
||||
insert_audit_logs(
|
||||
[
|
||||
AuditLog(
|
||||
timestamp=now - timedelta(seconds=index + 1),
|
||||
resources={"service.name": "signoz", "signoz.audit.resource.kind": "dashboard"},
|
||||
attributes={"signoz.audit.action": action, "signoz.audit.fields_changed": fields_changed},
|
||||
event_name=f"dashboard.{action}",
|
||||
)
|
||||
for action, values in changed_by_action.items()
|
||||
for index, fields_changed in enumerate(values)
|
||||
]
|
||||
# an event without the field is not counted
|
||||
+ [
|
||||
AuditLog(
|
||||
timestamp=now - timedelta(seconds=5),
|
||||
resources={"service.name": "signoz", "signoz.audit.resource.kind": "dashboard"},
|
||||
attributes={"signoz.audit.action": "update"},
|
||||
event_name="dashboard.update",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
|
||||
end_ms = int((now + timedelta(seconds=5)).timestamp() * 1000)
|
||||
queries = [
|
||||
build_heatmap_query(
|
||||
"A",
|
||||
"logs",
|
||||
"signoz.audit.fields_changed",
|
||||
source="audit",
|
||||
group_by=[build_group_by_field("signoz.audit.action", "string", "attribute")],
|
||||
filter_expression="signoz.audit.resource.kind = 'dashboard'",
|
||||
bucket_options=build_linear_bucket_options(20, 4),
|
||||
)
|
||||
]
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, queries, request_type=RequestType.HEATMAP)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
|
||||
|
||||
data = response.json()
|
||||
# 20 split into 4 gives (0, 5], (5, 10], (10, 15], (15, 20]; the axis stops
|
||||
# at the highest bucket reached, and 25 is past 20 so it counts in the
|
||||
# trailing overflow
|
||||
buckets = get_buckets(data, "A")
|
||||
assert buckets == pytest.approx([0.0, 5.0, 10.0])
|
||||
|
||||
series_by_action = index_series_by_label(get_all_series(data, "A"), "signoz.audit.action")
|
||||
totals_by_action = {action: [sum(column["values"][slot] for column in series["values"]) for slot in range(len(buckets) + 1)] for action, series in series_by_action.items()}
|
||||
assert totals_by_action == {
|
||||
"update": [0, 1, 1, 0],
|
||||
"delete": [0, 0, 0, 1],
|
||||
}
|
||||
86
tests/integration/tests/queriercommon/10_cache_time_shift.py
Normal file
86
tests/integration/tests/queriercommon/10_cache_time_shift.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
from uuid import uuid4
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import (
|
||||
build_aggregation,
|
||||
build_function,
|
||||
build_group_by_field,
|
||||
build_scalar_query,
|
||||
get_all_series,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
MINUTE = timedelta(minutes=1)
|
||||
|
||||
|
||||
def test_time_shift_partial_hit_matches_no_cache(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
"""
|
||||
The hour before the shifted one holds different counts, so a gap read
|
||||
one shift too far back returns wrong points instead of nothing.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
# the cache outlives the run, so a fixed filter would serve the previous
|
||||
# run's points back to this one
|
||||
run = uuid4().hex[:8]
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - 120 * MINUTE + minute * MINUTE + timedelta(seconds=1 + i),
|
||||
resources={"service.name": service},
|
||||
attributes={"run": run},
|
||||
body=f"{service} {minute} {i}",
|
||||
)
|
||||
for minute in range(60)
|
||||
for service, count in (("svc-a", 4), ("svc-b", 2))
|
||||
for i in range(count)
|
||||
]
|
||||
+ [
|
||||
Logs(
|
||||
timestamp=now - 180 * MINUTE + minute * MINUTE + timedelta(seconds=1 + i),
|
||||
resources={"service.name": service},
|
||||
attributes={"run": run},
|
||||
body=f"{service} earlier {minute} {i}",
|
||||
)
|
||||
for minute in range(60)
|
||||
for service, count in (("svc-a", 9), ("svc-b", 7))
|
||||
for i in range(count)
|
||||
]
|
||||
)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = build_scalar_query(
|
||||
name="A",
|
||||
signal="logs",
|
||||
aggregations=[build_aggregation("count()")],
|
||||
group_by=[build_group_by_field("service.name", "string", "resource")],
|
||||
filter_expression=f"run = '{run}'",
|
||||
step_interval=60,
|
||||
functions=[build_function("timeShift", 3600)],
|
||||
)
|
||||
start_ms = int((now - 60 * MINUTE).timestamp() * 1000)
|
||||
first_end_ms = int((now - 30 * MINUTE).timestamp() * 1000)
|
||||
second_end_ms = int((now - 10 * MINUTE).timestamp() * 1000)
|
||||
|
||||
warm = make_query_request(signoz, token, start_ms, first_end_ms, [query], no_cache=False)
|
||||
assert warm.status_code == HTTPStatus.OK, warm.text
|
||||
cached = make_query_request(signoz, token, start_ms, second_end_ms, [query], no_cache=False)
|
||||
assert cached.status_code == HTTPStatus.OK, cached.text
|
||||
fresh = make_query_request(signoz, token, start_ms, second_end_ms, [query], no_cache=True)
|
||||
assert fresh.status_code == HTTPStatus.OK, fresh.text
|
||||
|
||||
fresh_points = {service: {point["timestamp"]: point["value"] for point in series["values"]} for service, series in index_series_by_label(get_all_series(fresh.json(), "A"), "service.name").items()}
|
||||
assert set(fresh_points) == {"svc-a", "svc-b"}, fresh_points
|
||||
assert {service: len(points) for service, points in fresh_points.items()} == {"svc-a": 50, "svc-b": 50}
|
||||
|
||||
cached_points = {service: {point["timestamp"]: point["value"] for point in series["values"]} for service, series in index_series_by_label(get_all_series(cached.json(), "A"), "service.name").items()}
|
||||
assert cached_points == fresh_points
|
||||
@@ -0,0 +1,135 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
from uuid import uuid4
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import (
|
||||
build_aggregation,
|
||||
build_group_by_field,
|
||||
build_order_by,
|
||||
build_scalar_query,
|
||||
get_all_series,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
MINUTE = timedelta(minutes=1)
|
||||
|
||||
|
||||
def test_limited_group_by_partial_hit_matches_no_cache(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
"""
|
||||
The two halves have different winners, so a limit applied per cached
|
||||
piece returns the union of both instead of the window's top two.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
# the cache outlives the run, so a fixed filter would serve the previous
|
||||
# run's points back to this one
|
||||
run = uuid4().hex[:8]
|
||||
head_start = now - 60 * MINUTE
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=head_start + minute * MINUTE + timedelta(seconds=1 + i),
|
||||
resources={"service.name": service},
|
||||
attributes={"run": run},
|
||||
body=f"{service} {minute} {i}",
|
||||
)
|
||||
for minute in range(40)
|
||||
for service, count in (("svc-a", 10 if minute < 20 else 1), ("svc-b", 8 if minute < 20 else 1), ("svc-c", 1 if minute < 20 else 10), ("svc-d", 1 if minute < 20 else 8))
|
||||
for i in range(count)
|
||||
]
|
||||
)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = build_scalar_query(
|
||||
name="A",
|
||||
signal="logs",
|
||||
aggregations=[build_aggregation("count()")],
|
||||
group_by=[build_group_by_field("service.name", "string", "resource")],
|
||||
order=[build_order_by("count()", "desc")],
|
||||
limit=2,
|
||||
filter_expression=f"run = '{run}'",
|
||||
step_interval=60,
|
||||
)
|
||||
head_start_ms = int(head_start.timestamp() * 1000)
|
||||
head_end_ms = int((head_start + 20 * MINUTE).timestamp() * 1000)
|
||||
tail_end_ms = int((head_start + 40 * MINUTE).timestamp() * 1000)
|
||||
|
||||
warm = make_query_request(signoz, token, head_start_ms, head_end_ms, [query], no_cache=False)
|
||||
assert warm.status_code == HTTPStatus.OK, warm.text
|
||||
cached = make_query_request(signoz, token, head_start_ms, tail_end_ms, [query], no_cache=False)
|
||||
assert cached.status_code == HTTPStatus.OK, cached.text
|
||||
fresh = make_query_request(signoz, token, head_start_ms, tail_end_ms, [query], no_cache=True)
|
||||
assert fresh.status_code == HTTPStatus.OK, fresh.text
|
||||
|
||||
fresh_points = {service: {point["timestamp"]: point["value"] for point in series["values"]} for service, series in index_series_by_label(get_all_series(fresh.json(), "A"), "service.name").items()}
|
||||
assert set(fresh_points) == {"svc-a", "svc-c"}, fresh_points
|
||||
assert {service: len(points) for service, points in fresh_points.items()} == {"svc-a": 40, "svc-c": 40}
|
||||
|
||||
cached_points = {service: {point["timestamp"]: point["value"] for point in series["values"]} for service, series in index_series_by_label(get_all_series(cached.json(), "A"), "service.name").items()}
|
||||
assert cached_points == fresh_points
|
||||
|
||||
|
||||
def test_limited_group_by_shrunk_window_matches_no_cache(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
"""
|
||||
svc-a wins the full window and svc-b wins its second half, so a cache
|
||||
that keeps the window's winner serves the wrong series for the half.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
# the cache outlives the run, so a fixed filter would serve the previous
|
||||
# run's points back to this one
|
||||
run = uuid4().hex[:8]
|
||||
start = now - 40 * MINUTE
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=start + minute * MINUTE + timedelta(seconds=1 + i),
|
||||
resources={"service.name": service},
|
||||
attributes={"run": run},
|
||||
body=f"{service} {minute} {i}",
|
||||
)
|
||||
for minute in range(10)
|
||||
for service, count in (("svc-a", 20 if minute < 5 else 1), ("svc-b", 1 if minute < 5 else 10))
|
||||
for i in range(count)
|
||||
]
|
||||
)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = build_scalar_query(
|
||||
name="A",
|
||||
signal="logs",
|
||||
aggregations=[build_aggregation("count()")],
|
||||
group_by=[build_group_by_field("service.name", "string", "resource")],
|
||||
order=[build_order_by("count()", "desc")],
|
||||
limit=1,
|
||||
filter_expression=f"run = '{run}'",
|
||||
step_interval=60,
|
||||
)
|
||||
start_ms = int(start.timestamp() * 1000)
|
||||
middle_ms = int((start + 5 * MINUTE).timestamp() * 1000)
|
||||
end_ms = int((start + 10 * MINUTE).timestamp() * 1000)
|
||||
|
||||
warm = make_query_request(signoz, token, start_ms, end_ms, [query], no_cache=False)
|
||||
assert warm.status_code == HTTPStatus.OK, warm.text
|
||||
cached = make_query_request(signoz, token, middle_ms, end_ms, [query], no_cache=False)
|
||||
assert cached.status_code == HTTPStatus.OK, cached.text
|
||||
fresh = make_query_request(signoz, token, middle_ms, end_ms, [query], no_cache=True)
|
||||
assert fresh.status_code == HTTPStatus.OK, fresh.text
|
||||
|
||||
fresh_points = {service: {point["timestamp"]: point["value"] for point in series["values"]} for service, series in index_series_by_label(get_all_series(fresh.json(), "A"), "service.name").items()}
|
||||
assert set(fresh_points) == {"svc-b"}, fresh_points
|
||||
assert sorted(fresh_points["svc-b"].values()) == [10] * 5, fresh_points
|
||||
|
||||
cached_points = {service: {point["timestamp"]: point["value"] for point in series["values"]} for service, series in index_series_by_label(get_all_series(cached.json(), "A"), "service.name").items()}
|
||||
assert cached_points == fresh_points
|
||||
@@ -2,6 +2,8 @@ from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
@@ -12,6 +14,9 @@ from fixtures.querier import (
|
||||
build_order_by,
|
||||
build_scalar_query,
|
||||
find_named_result,
|
||||
get_all_series,
|
||||
get_error_message,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
@@ -191,3 +196,138 @@ def test_logs_formula_orderby_and_limit(
|
||||
assert len(f3_services) == 3, f"F3: expected 3 rows after limit, got {len(f3_services)}"
|
||||
assert f3_values == f4_values[:3], f"F3 values {f3_values} do not match F4[:3] values {f4_values[:3]}"
|
||||
assert set(f3_services) == set(f4_services[:3]), f"F3 services {f3_services} do not match F4[:3] services {f4_services[:3]}"
|
||||
|
||||
|
||||
# Logs per service at 5, 4, 3, 2, and 1 minutes before now. Totals: a 15, b 17, c 5, d 15.
|
||||
FORMULA_HAVING_COUNTS = {
|
||||
"service-a": [1, 4, 2, 5, 3],
|
||||
"service-b": [6, 1, 1, 2, 7],
|
||||
"service-c": [1, 1, 1, 1, 1],
|
||||
"service-d": [3, 3, 3, 3, 3],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query_having,formula_having,expected_services",
|
||||
[
|
||||
pytest.param("count() > 4 AND count() < 16", "F1 > 4 AND F1 < 16", {"service-a", "service-b"}, id="and_range"),
|
||||
pytest.param("count() IN (1, 5, 17) OR count() > 15", "__result IN (1, 5, 17) OR __result > 15", {"service-a", "service-b", "service-c"}, id="in_or"),
|
||||
pytest.param("NOT (count() * 2 - 1 > 9)", "NOT (F1 * 2 - 1 > 9)", {"service-a", "service-b", "service-c", "service-d"}, id="not_arithmetic"),
|
||||
],
|
||||
)
|
||||
def test_logs_formula_having_time_series_matches_query_having(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
query_having: str,
|
||||
formula_having: str,
|
||||
expected_services: set[str],
|
||||
) -> None:
|
||||
"""
|
||||
The querier computes formulas in Go, so a formula having runs in Go. For the
|
||||
formula `A`, it must keep the same points that SQL HAVING keeps on query A.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_logs([Logs(timestamp=now - timedelta(minutes=5 - minute), resources={"service.name": service}, body=f"{service} log {minute}-{i}") for service, counts in FORMULA_HAVING_COUNTS.items() for minute, count in enumerate(counts) for i in range(count)])
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
group_by = [build_group_by_field("service.name", "string", "resource")]
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=15)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type="time_series",
|
||||
queries=[
|
||||
build_scalar_query(name="A", signal="logs", aggregations=[build_aggregation("count()")], group_by=group_by, disabled=True),
|
||||
build_scalar_query(name="B", signal="logs", aggregations=[build_aggregation("count()")], group_by=group_by, having_expression=query_having),
|
||||
build_formula_query("F1", "A", having_expression=formula_having),
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
query_series = index_series_by_label(get_all_series(response.json(), "B"), "service.name")
|
||||
formula_series = index_series_by_label(get_all_series(response.json(), "F1"), "service.name")
|
||||
assert set(formula_series) == expected_services
|
||||
assert set(query_series) == expected_services
|
||||
for service in expected_services:
|
||||
query_points = sorted((value["timestamp"], value["value"]) for value in query_series[service]["values"])
|
||||
formula_points = sorted((value["timestamp"], value["value"]) for value in formula_series[service]["values"])
|
||||
assert formula_points == query_points, service
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query_having,formula_having,expected_services",
|
||||
[
|
||||
pytest.param("count() > 4 AND count() < 16", "F1 > 4 AND F1 < 16", {"service-a", "service-c", "service-d"}, id="and_range"),
|
||||
pytest.param("count() IN (1, 5, 17) OR count() > 15", "__result IN (1, 5, 17) OR __result > 15", {"service-b", "service-c"}, id="in_or"),
|
||||
pytest.param("NOT (count() * 2 - 1 > 9)", "NOT (F1 * 2 - 1 > 9)", {"service-c"}, id="not_arithmetic"),
|
||||
],
|
||||
)
|
||||
def test_logs_formula_having_scalar_matches_query_having(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
query_having: str,
|
||||
formula_having: str,
|
||||
expected_services: set[str],
|
||||
) -> None:
|
||||
"""
|
||||
For the formula `A` in a scalar request, the formula having must keep the
|
||||
same rows that SQL HAVING keeps on query A.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_logs([Logs(timestamp=now - timedelta(minutes=5 - minute), resources={"service.name": service}, body=f"{service} log {minute}-{i}") for service, counts in FORMULA_HAVING_COUNTS.items() for minute, count in enumerate(counts) for i in range(count)])
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
group_by = [build_group_by_field("service.name", "string", "resource")]
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=15)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type="scalar",
|
||||
queries=[
|
||||
build_scalar_query(name="A", signal="logs", aggregations=[build_aggregation("count()")], group_by=group_by, disabled=True),
|
||||
build_scalar_query(name="B", signal="logs", aggregations=[build_aggregation("count()")], group_by=group_by, having_expression=query_having),
|
||||
build_formula_query("F1", "A", having_expression=formula_having),
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
results = response.json()["data"]["data"]["results"]
|
||||
values_by_query = {}
|
||||
for name in ("B", "F1"):
|
||||
result = find_named_result(results, name)
|
||||
assert result is not None, name
|
||||
service_column = next(i for i, column in enumerate(result["columns"]) if column["name"] == "service.name")
|
||||
value_column = next(i for i, column in enumerate(result["columns"]) if column["columnType"] == "aggregation")
|
||||
values_by_query[name] = {row[service_column]: row[value_column] for row in result["data"]}
|
||||
assert set(values_by_query["F1"]) == expected_services
|
||||
assert values_by_query["F1"] == values_by_query["B"]
|
||||
|
||||
|
||||
def test_logs_formula_having_invalid_reference(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""A formula having can reference only the formula value, so `count()` is a bad request."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=15)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type="time_series",
|
||||
queries=[
|
||||
build_scalar_query(name="A", signal="logs", aggregations=[build_aggregation("count()")], disabled=True),
|
||||
build_formula_query("F1", "A", having_expression="count() > 0"),
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert get_error_message(response.json()) == "Invalid references in `Having` expression: [count]"
|
||||
|
||||
207
tests/integration/tests/querierlogs/17_heatmap.py
Normal file
207
tests/integration/tests/querierlogs/17_heatmap.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import (
|
||||
RequestType,
|
||||
assert_distribution_sums_heatmap,
|
||||
build_aggregation,
|
||||
build_group_by_field,
|
||||
build_heatmap_query,
|
||||
build_linear_bucket_options,
|
||||
build_log_bucket_options,
|
||||
build_order_by,
|
||||
build_scalar_query,
|
||||
get_all_series,
|
||||
get_buckets,
|
||||
get_error_message,
|
||||
get_heatmap_columns,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
|
||||
def test_linear_heatmap_grouped(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
bytes_by_service = {"heatmap-svc-a": [50, 250], "heatmap-svc-b": [950, 5000]}
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=index + 1),
|
||||
resources={"service.name": service},
|
||||
attributes={"response_bytes": response_bytes},
|
||||
body=f"{service} served {response_bytes} bytes",
|
||||
)
|
||||
for service, values in bytes_by_service.items()
|
||||
for index, response_bytes in enumerate(values)
|
||||
]
|
||||
# a log without the field is not counted
|
||||
+ [
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=index + 1),
|
||||
resources={"service.name": "heatmap-svc-a"},
|
||||
body="no response size",
|
||||
)
|
||||
for index in range(2)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
|
||||
end_ms = int((now + timedelta(seconds=5)).timestamp() * 1000)
|
||||
queries = [
|
||||
build_heatmap_query(
|
||||
"A",
|
||||
"logs",
|
||||
"response_bytes",
|
||||
group_by=[build_group_by_field("service.name", "string", "resource")],
|
||||
filter_expression="service.name IN ('heatmap-svc-a', 'heatmap-svc-b')",
|
||||
bucket_options=build_linear_bucket_options(1000, 10),
|
||||
)
|
||||
]
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, queries, request_type=RequestType.HEATMAP)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
|
||||
|
||||
data = response.json()
|
||||
# 1000 split into 10 gives (0, 100], (100, 200], ... (900, 1000], and 5000
|
||||
# is past 1000 so it counts in the trailing overflow
|
||||
buckets = get_buckets(data, "A")
|
||||
assert buckets == pytest.approx([0.0, 100.0, 200.0, 300.0, 400.0, 500.0, 600.0, 700.0, 800.0, 900.0, 1000.0])
|
||||
|
||||
series_by_service = index_series_by_label(get_all_series(data, "A"), "service.name")
|
||||
assert set(series_by_service) == set(bytes_by_service)
|
||||
|
||||
totals_by_service = {service: [sum(column["values"][slot] for column in series["values"]) for slot in range(len(buckets) + 1)] for service, series in series_by_service.items()}
|
||||
assert totals_by_service == {
|
||||
"heatmap-svc-a": [0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
"heatmap-svc-b": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1],
|
||||
}
|
||||
|
||||
|
||||
def test_log_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=index + 1),
|
||||
resources={"service.name": "heatmap-log-axis"},
|
||||
attributes={"queue_depth": queue_depth},
|
||||
body=f"queue depth {queue_depth}",
|
||||
)
|
||||
for index, queue_depth in enumerate([100, 300, 600])
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
|
||||
end_ms = int((now + timedelta(seconds=5)).timestamp() * 1000)
|
||||
queries = [
|
||||
build_heatmap_query(
|
||||
"A",
|
||||
"logs",
|
||||
"attribute.queue_depth",
|
||||
order=[build_order_by("attribute.queue_depth")],
|
||||
filter_expression="service.name = 'heatmap-log-axis'",
|
||||
bucket_options=build_log_bucket_options(0),
|
||||
)
|
||||
]
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, queries, request_type=RequestType.HEATMAP)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
|
||||
|
||||
data = response.json()
|
||||
# a doubling per bucket, with one rung under the lowest bucket reached so
|
||||
# its lower bound is on the axis; (128, 256] holds nothing but stays
|
||||
buckets = get_buckets(data, "A")
|
||||
assert buckets == pytest.approx([64.0, 128.0, 256.0, 512.0, 1024.0])
|
||||
assert [sum(column["values"][slot] for column in get_heatmap_columns(data, "A")) for slot in range(len(buckets) + 1)] == [0, 1, 0, 1, 1, 0]
|
||||
|
||||
|
||||
def test_default_axis(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=index + 1),
|
||||
resources={"service.name": "heatmap-default-axis"},
|
||||
attributes={"batch_size": 1000},
|
||||
body="batch of 1000",
|
||||
)
|
||||
for index in range(2)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
|
||||
end_ms = int((now + timedelta(seconds=5)).timestamp() * 1000)
|
||||
queries = [build_heatmap_query("A", "logs", "batch_size", filter_expression="service.name = 'heatmap-default-axis'")]
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, queries, request_type=RequestType.HEATMAP)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
|
||||
|
||||
data = response.json()
|
||||
# no bucketOptions is the finest log axis, 16 buckets per doubling: 1000
|
||||
# sits in (2^(159/16), 2^10]
|
||||
buckets = get_buckets(data, "A")
|
||||
totals = [sum(column["values"][slot] for column in get_heatmap_columns(data, "A")) for slot in range(len(buckets) + 1)]
|
||||
holding = [slot for slot, total in enumerate(totals) if total]
|
||||
assert holding == [len(buckets) - 1]
|
||||
assert totals[holding[0]] == 2
|
||||
assert buckets[-2:] == pytest.approx([2 ** (159 / 16), 1024.0])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"aggregations, expected_message",
|
||||
[
|
||||
pytest.param(
|
||||
[build_aggregation("count()")],
|
||||
'a logs or traces heatmap counts events by the field named as its aggregation, got "count()"',
|
||||
id="count_aggregation",
|
||||
),
|
||||
pytest.param(
|
||||
[build_aggregation("response_bytes"), build_aggregation("queue_depth")],
|
||||
"a heatmap renders one bucket axis, but the query has 2 aggregations",
|
||||
id="two_aggregations",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_heatmap_rejections(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
aggregations: list[dict],
|
||||
expected_message: str,
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_scalar_query("A", "logs", aggregations)],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert expected_message in get_error_message(response.json())
|
||||
@@ -128,6 +128,15 @@ request_types = pytest.mark.parametrize(
|
||||
"having is not supported for {request_type} requests",
|
||||
id="having_on_the_query",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
build_builder_query("A", METRIC_NAME, "max", "max", disabled=True),
|
||||
build_formula_query("F1", "A", having_expression="F1 > 1"),
|
||||
],
|
||||
{},
|
||||
"having is not supported for {request_type} requests",
|
||||
id="having_on_the_formula",
|
||||
),
|
||||
pytest.param(
|
||||
[build_builder_query("A", METRIC_NAME, "max", "max", bucket_options={"kind": "quadratic", "spec": {}})],
|
||||
{},
|
||||
|
||||
113
tests/integration/tests/queriertraces/16_heatmap.py
Normal file
113
tests/integration/tests/queriertraces/16_heatmap.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querier import (
|
||||
RequestType,
|
||||
assert_distribution_sums_heatmap,
|
||||
build_heatmap_query,
|
||||
build_linear_bucket_options,
|
||||
build_log_bucket_options,
|
||||
get_buckets,
|
||||
get_heatmap_columns,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind
|
||||
|
||||
|
||||
def test_duration_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
durations = [timedelta(0)] + [timedelta(milliseconds=5)] * 6 + [timedelta(seconds=1)] * 3 + [timedelta(seconds=30)]
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(minutes=2, seconds=index),
|
||||
duration=duration,
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name="GET /cart",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
resources={"service.name": "heatmap-cart"},
|
||||
)
|
||||
for index, duration in enumerate(durations)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
queries = [build_heatmap_query("A", "traces", "duration_nano", filter_expression="service.name = 'heatmap-cart'", bucket_options=build_log_bucket_options(0))]
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, queries, request_type=RequestType.HEATMAP)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
|
||||
|
||||
data = response.json()
|
||||
# a doubling per bucket in nanoseconds: 5ms is in (2^22, 2^23], 1s in
|
||||
# (2^29, 2^30] and 30s in (2^34, 2^35], and 2^22 is the rung under 5ms; a
|
||||
# 0ns span is counted at 0
|
||||
buckets = get_buckets(data, "A")
|
||||
assert buckets == pytest.approx([0.0] + [float(2**power) for power in range(22, 36)])
|
||||
|
||||
totals = [sum(column["values"][slot] for column in get_heatmap_columns(data, "A")) for slot in range(len(buckets) + 1)]
|
||||
assert {buckets[slot]: total for slot, total in enumerate(totals) if total} == {0.0: 1, 2.0**23: 6, 2.0**30: 3, 2.0**35: 1}
|
||||
assert sum(totals) == len(durations)
|
||||
|
||||
|
||||
def test_span_attribute_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
rows_returned = [5, 5, 5, 45, 45, 250]
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(minutes=2, seconds=index),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name="SELECT orders",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
resources={"service.name": "heatmap-orders-db"},
|
||||
attributes={"db.rows_returned": rows},
|
||||
)
|
||||
for index, rows in enumerate(rows_returned)
|
||||
]
|
||||
# a span without the attribute is not counted
|
||||
+ [
|
||||
Traces(
|
||||
timestamp=now - timedelta(minutes=2, seconds=index),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name="BEGIN",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
resources={"service.name": "heatmap-orders-db"},
|
||||
)
|
||||
for index in range(2)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
queries = [build_heatmap_query("A", "traces", "db.rows_returned", filter_expression="service.name = 'heatmap-orders-db'", bucket_options=build_linear_bucket_options(100, 10))]
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, queries, request_type=RequestType.HEATMAP)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
|
||||
|
||||
data = response.json()
|
||||
# 100 split into 10 gives (0, 10], (10, 20], ... (90, 100]: 5 is in the
|
||||
# first, 45 in (40, 50], and 250 is past 100 so it counts in the overflow
|
||||
buckets = get_buckets(data, "A")
|
||||
assert buckets == pytest.approx([0.0, 10.0, 20.0, 30.0, 40.0, 50.0])
|
||||
assert [sum(column["values"][slot] for column in get_heatmap_columns(data, "A")) for slot in range(len(buckets) + 1)] == [0, 3, 0, 0, 0, 2, 1]
|
||||
Reference in New Issue
Block a user