mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-11 14:00:42 +01:00
Compare commits
149 Commits
main
...
nv/heatmap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f1b1476f4 | ||
|
|
0bea671a2d | ||
|
|
0b1695c9c2 | ||
|
|
b4f42cf388 | ||
|
|
65a8a58600 | ||
|
|
a3c90ab132 | ||
|
|
da4c5578d3 | ||
|
|
bf01867c3c | ||
|
|
41187a5fca | ||
|
|
d04dc6f7db | ||
|
|
71badcf65c | ||
|
|
68fe15d5fc | ||
|
|
f8aded8c8e | ||
|
|
f6a6793e96 | ||
|
|
452046d70c | ||
|
|
2e9c9067f3 | ||
|
|
2ddd45cb8c | ||
|
|
8809d8a7ac | ||
|
|
21edb05f2b | ||
|
|
f659205866 | ||
|
|
215f6f8e0f | ||
|
|
802f7e4a3d | ||
|
|
81f1105fbc | ||
|
|
575aa57426 | ||
|
|
f75d3d8724 | ||
|
|
43538b92db | ||
|
|
ef65c2c08c | ||
|
|
8e41d3d30d | ||
|
|
b249ab8275 | ||
|
|
61bca035a3 | ||
|
|
9983b3344b | ||
|
|
1313a289b6 | ||
|
|
82a789c19e | ||
|
|
ec09873573 | ||
|
|
00cc773792 | ||
|
|
cf5dd79216 | ||
|
|
6546a0abc6 | ||
|
|
3874f3a6ec | ||
|
|
86db0fd26b | ||
|
|
f382a958db | ||
|
|
7c3481b230 | ||
|
|
24f3116527 | ||
|
|
17f0f49398 | ||
|
|
f681bacdcb | ||
|
|
ded4ed3d76 | ||
|
|
1f84f9c166 | ||
|
|
87127b580c | ||
|
|
fc4bd13fce | ||
|
|
075a4b164d | ||
|
|
2170189d14 | ||
|
|
b7720b655e | ||
|
|
f34973b897 | ||
|
|
d9d041a8fa | ||
|
|
a0e66e3592 | ||
|
|
a0dff26fbc | ||
|
|
db0b1b2b2a | ||
|
|
fd8ed18592 | ||
|
|
392b5826fd | ||
|
|
4b4023d706 | ||
|
|
f8d803d787 | ||
|
|
a33320c665 | ||
|
|
695a481989 | ||
|
|
3fc763b95b | ||
|
|
027b65574d | ||
|
|
43ec98b309 | ||
|
|
a196a436be | ||
|
|
3f456b6602 | ||
|
|
f777e068c3 | ||
|
|
5a690695e6 | ||
|
|
bf362d15db | ||
|
|
7e0f70ffa7 | ||
|
|
590046adf3 | ||
|
|
106cb9f6c2 | ||
|
|
f95699b1f7 | ||
|
|
1e75b66379 | ||
|
|
b80013d075 | ||
|
|
4a6e25708d | ||
|
|
3b8f9a7830 | ||
|
|
68bff5b053 | ||
|
|
732c8ae18a | ||
|
|
6911630c2a | ||
|
|
2d78d8add9 | ||
|
|
8e47c7d337 | ||
|
|
991e734c33 | ||
|
|
40c881b8f5 | ||
|
|
a2c8dd5275 | ||
|
|
32ae7a26d5 | ||
|
|
4067b04fa2 | ||
|
|
70409f5ee3 | ||
|
|
5d9953bb7e | ||
|
|
edfff0ee72 | ||
|
|
44556060a3 | ||
|
|
545e0342d2 | ||
|
|
2f559f34d3 | ||
|
|
e4d90b91b7 | ||
|
|
f33616a598 | ||
|
|
2ac94fc2fc | ||
|
|
7292a6946b | ||
|
|
dff49a4135 | ||
|
|
690c207519 | ||
|
|
d9b6d1f425 | ||
|
|
1eb974d91a | ||
|
|
1141f6bf14 | ||
|
|
a3fb926726 | ||
|
|
fe1e86f385 | ||
|
|
e30d9a1a14 | ||
|
|
c9a345713c | ||
|
|
22f8eba44a | ||
|
|
e8ad9044d5 | ||
|
|
576cd5052a | ||
|
|
4214ccf93d | ||
|
|
63f5c17238 | ||
|
|
fd6abd67dd | ||
|
|
a78c35d61c | ||
|
|
b2c4916a86 | ||
|
|
feff1a0179 | ||
|
|
eafac1be69 | ||
|
|
510d6985d0 | ||
|
|
758aacb065 | ||
|
|
5599c352d0 | ||
|
|
96d0a2340c | ||
|
|
c6b415ad04 | ||
|
|
9b922f297f | ||
|
|
e81a4c4046 | ||
|
|
9ba13848ea | ||
|
|
2578f25dd4 | ||
|
|
82352973bb | ||
|
|
3eb55692f1 | ||
|
|
61ceb6d300 | ||
|
|
e33fc20579 | ||
|
|
b130a61049 | ||
|
|
132e9bb740 | ||
|
|
9d577731e1 | ||
|
|
4d54568fe1 | ||
|
|
6f82624523 | ||
|
|
da0bc4f438 | ||
|
|
2dcb764632 | ||
|
|
1bc25e16b8 | ||
|
|
eebc6b400d | ||
|
|
30c4508113 | ||
|
|
c4fe619920 | ||
|
|
d597adddda | ||
|
|
9a05f33e7a | ||
|
|
3812937c6f | ||
|
|
34aa719cba | ||
|
|
cdfce4c4ab | ||
|
|
d8ef672f83 | ||
|
|
00efffc127 | ||
|
|
dec922a83f |
@@ -3474,6 +3474,79 @@ components:
|
||||
- tags
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesHeatmapAxes:
|
||||
properties:
|
||||
yScale:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapYScale'
|
||||
type: object
|
||||
DashboardtypesHeatmapChartAppearance:
|
||||
properties:
|
||||
colors:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColors'
|
||||
type: object
|
||||
DashboardtypesHeatmapColorMode:
|
||||
enum:
|
||||
- palette
|
||||
- opacity
|
||||
type: string
|
||||
DashboardtypesHeatmapColorScale:
|
||||
enum:
|
||||
- log
|
||||
- sqrt
|
||||
- linear
|
||||
type: string
|
||||
DashboardtypesHeatmapColors:
|
||||
properties:
|
||||
fill:
|
||||
type: string
|
||||
maxCount:
|
||||
nullable: true
|
||||
type: number
|
||||
minCount:
|
||||
nullable: true
|
||||
type: number
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
|
||||
palette:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapPalette'
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
|
||||
steps:
|
||||
type: integer
|
||||
type: object
|
||||
DashboardtypesHeatmapPalette:
|
||||
enum:
|
||||
- ice
|
||||
- moss
|
||||
- rust
|
||||
- graphite
|
||||
- ember
|
||||
- lagoon
|
||||
- orchid
|
||||
- verdant
|
||||
- lava
|
||||
- beacon
|
||||
type: string
|
||||
DashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapChartAppearance'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesHeatmapYScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -3826,6 +3899,7 @@ components:
|
||||
discriminator:
|
||||
mapping:
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
@@ -3841,6 +3915,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -3851,6 +3926,7 @@ components:
|
||||
- signoz/TablePanel
|
||||
- signoz/HistogramPanel
|
||||
- signoz/ListPanel
|
||||
- signoz/HeatmapPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
@@ -3864,6 +3940,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/HeatmapPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -7395,10 +7483,7 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
type: array
|
||||
meta:
|
||||
properties:
|
||||
unit:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
|
||||
predictedSeries:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
@@ -7413,12 +7498,51 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5Bucket:
|
||||
Querybuildertypesv5AggregationMeta:
|
||||
properties:
|
||||
step:
|
||||
format: double
|
||||
type: number
|
||||
buckets:
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
type: array
|
||||
unit:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5BucketOptions:
|
||||
discriminator:
|
||||
mapping:
|
||||
linear: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
|
||||
log: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
|
||||
type: object
|
||||
Querybuildertypesv5BucketOptionsLinear:
|
||||
properties:
|
||||
kind:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
|
||||
spec:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LinearBucketsSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
Querybuildertypesv5BucketOptionsLog:
|
||||
properties:
|
||||
kind:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
|
||||
spec:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LogBucketsSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
Querybuildertypesv5BucketsKind:
|
||||
enum:
|
||||
- linear
|
||||
- log
|
||||
type: string
|
||||
Querybuildertypesv5BuilderQuerySpec:
|
||||
discriminator:
|
||||
mapping:
|
||||
@@ -7599,6 +7723,16 @@ components:
|
||||
value:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5LinearBucketsSpec:
|
||||
properties:
|
||||
maxValue:
|
||||
format: double
|
||||
type: number
|
||||
numBuckets:
|
||||
type: integer
|
||||
required:
|
||||
- maxValue
|
||||
type: object
|
||||
Querybuildertypesv5LogAggregation:
|
||||
properties:
|
||||
alias:
|
||||
@@ -7606,6 +7740,12 @@ components:
|
||||
expression:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5LogBucketsSpec:
|
||||
properties:
|
||||
scale:
|
||||
nullable: true
|
||||
type: integer
|
||||
type: object
|
||||
Querybuildertypesv5MetricAggregation:
|
||||
properties:
|
||||
comparisonSpaceAggregationParam:
|
||||
@@ -7686,6 +7826,8 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5QueryBuilderFormula:
|
||||
properties:
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
disabled:
|
||||
type: boolean
|
||||
expression:
|
||||
@@ -7716,6 +7858,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LogAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -7777,6 +7921,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5MetricAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -7838,6 +7984,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -8163,6 +8311,7 @@ components:
|
||||
- raw
|
||||
- raw_stream
|
||||
- trace
|
||||
- heatmap
|
||||
type: string
|
||||
Querybuildertypesv5ScalarData:
|
||||
properties:
|
||||
@@ -8237,8 +8386,6 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5TimeSeriesValue:
|
||||
properties:
|
||||
bucket:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Bucket'
|
||||
partial:
|
||||
type: boolean
|
||||
timestamp:
|
||||
|
||||
@@ -4079,6 +4079,53 @@ export interface Querybuildertypesv5LogAggregationDTO {
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5BucketOptionsLinearDTOKind {
|
||||
linear = 'linear',
|
||||
}
|
||||
export interface Querybuildertypesv5LinearBucketsSpecDTO {
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
maxValue: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
numBuckets?: number;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5BucketOptionsLinearDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @enum linear
|
||||
*/
|
||||
kind: Querybuildertypesv5BucketOptionsLinearDTOKind;
|
||||
spec: Querybuildertypesv5LinearBucketsSpecDTO;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5BucketOptionsLogDTOKind {
|
||||
log = 'log',
|
||||
}
|
||||
export interface Querybuildertypesv5LogBucketsSpecDTO {
|
||||
/**
|
||||
* @type integer,null
|
||||
*/
|
||||
scale?: number | null;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5BucketOptionsLogDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @enum log
|
||||
*/
|
||||
kind: Querybuildertypesv5BucketOptionsLogDTOKind;
|
||||
spec: Querybuildertypesv5LogBucketsSpecDTO;
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5BucketOptionsDTO =
|
||||
| Querybuildertypesv5BucketOptionsLinearDTO
|
||||
| Querybuildertypesv5BucketOptionsLogDTO;
|
||||
|
||||
export interface Querybuildertypesv5FilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -4272,6 +4319,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5LogAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4399,6 +4447,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5MetricAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4474,6 +4523,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4929,6 +4979,7 @@ export enum Querybuildertypesv5RequestTypeDTO {
|
||||
raw = 'raw',
|
||||
raw_stream = 'raw_stream',
|
||||
trace = 'trace',
|
||||
heatmap = 'heatmap',
|
||||
}
|
||||
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind {
|
||||
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
|
||||
@@ -4975,6 +5026,7 @@ export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -8542,16 +8594,7 @@ export interface Querybuildertypesv5LabelDTO {
|
||||
value?: Querybuildertypesv5LabelDTOValue;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5BucketDTO {
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5TimeSeriesValueDTO {
|
||||
bucket?: Querybuildertypesv5BucketDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -9102,12 +9145,16 @@ export interface PromotetypesPromotePathDTO {
|
||||
promote?: boolean;
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5AggregationBucketDTOMeta = {
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
buckets?: number[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
/**
|
||||
@@ -9126,10 +9173,7 @@ export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
* @type array
|
||||
*/
|
||||
lowerBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
meta?: Querybuildertypesv5AggregationBucketDTOMeta;
|
||||
meta?: Querybuildertypesv5AggregationMetaDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -9144,6 +9188,10 @@ export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
upperBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5BucketsKindDTO {
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
}
|
||||
export type Querybuildertypesv5ColumnDescriptorDTOMeta = {
|
||||
/**
|
||||
* @type string
|
||||
|
||||
@@ -451,7 +451,7 @@ func (bc *bucketCache) mergeBuckets(ctx context.Context, buckets []*qbtypes.Cach
|
||||
// Merge values based on type
|
||||
var mergedValue any
|
||||
switch resultType {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
mergedValue = bc.mergeTimeSeriesValues(ctx, buckets)
|
||||
// Raw and Scalar types are not cached, so no merge needed
|
||||
}
|
||||
@@ -476,14 +476,36 @@ func (bc *bucketCache) mergeTimeSeriesValues(ctx context.Context, buckets []*qbt
|
||||
}
|
||||
seriesMap := make(map[seriesKey]*qbtypes.TimeSeries, estimatedSeries)
|
||||
|
||||
decodedTimeSeriesData := make([]*qbtypes.TimeSeriesData, 0, len(buckets))
|
||||
|
||||
// Alias and Meta are taken from whichever cached bucket covers the latest
|
||||
// range, and the buckets do not arrive in StartMs order, so keep the winner
|
||||
// per AggregationBucket.Index alongside the StartMs that won it.
|
||||
aggregationIndexToLatest := map[int]*qbtypes.AggregationBucket{}
|
||||
aggregationIndexToLatestStartMs := map[int]uint64{}
|
||||
|
||||
for _, bucket := range buckets {
|
||||
var tsData *qbtypes.TimeSeriesData
|
||||
if err := json.Unmarshal(bucket.Value, &tsData); err != nil {
|
||||
bc.logger.ErrorContext(ctx, "failed to unmarshal time series data", errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
decodedTimeSeriesData = append(decodedTimeSeriesData, tsData)
|
||||
|
||||
for _, aggBucket := range tsData.Aggregations {
|
||||
if _, seen := aggregationIndexToLatest[aggBucket.Index]; !seen || bucket.StartMs >= aggregationIndexToLatestStartMs[aggBucket.Index] {
|
||||
aggregationIndexToLatest[aggBucket.Index] = aggBucket
|
||||
aggregationIndexToLatestStartMs[aggBucket.Index] = bucket.StartMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mergedUpperBounds := qbtypes.MergeBucketUpperBounds(decodedTimeSeriesData...)
|
||||
|
||||
for _, tsData := range decodedTimeSeriesData {
|
||||
for _, aggBucket := range tsData.Aggregations {
|
||||
aggBucket.ReindexValuesToNewUpperBounds(mergedUpperBounds[aggBucket.Index])
|
||||
|
||||
for _, series := range aggBucket.Series {
|
||||
// Create series key from labels
|
||||
key := seriesKey{
|
||||
@@ -556,10 +578,15 @@ func (bc *bucketCache) mergeTimeSeriesValues(ctx context.Context, buckets []*qbt
|
||||
}
|
||||
}
|
||||
|
||||
result.Aggregations = append(result.Aggregations, &qbtypes.AggregationBucket{
|
||||
aggBucket := &qbtypes.AggregationBucket{
|
||||
Index: index,
|
||||
Series: seriesList,
|
||||
})
|
||||
}
|
||||
if latest, ok := aggregationIndexToLatest[index]; ok {
|
||||
aggBucket.Alias = latest.Alias
|
||||
aggBucket.Meta = latest.Meta
|
||||
}
|
||||
result.Aggregations = append(result.Aggregations, aggBucket)
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -572,7 +599,7 @@ func (bc *bucketCache) isEmptyResult(result *qbtypes.Result) (isEmpty bool, isFi
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
// No aggregations at all means truly empty
|
||||
if len(tsData.Aggregations) == 0 {
|
||||
@@ -699,14 +726,19 @@ func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, fluxBoun
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
// Trim time series data
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok && tsData != nil {
|
||||
trimmedData := &qbtypes.TimeSeriesData{}
|
||||
|
||||
for _, aggBucket := range tsData.Aggregations {
|
||||
// Meta has to survive the trim: a heatmap's counts are
|
||||
// positional against Meta.Buckets, so a cached bucket that
|
||||
// lost its axis cannot be read back against anything.
|
||||
trimmedBucket := &qbtypes.AggregationBucket{
|
||||
Index: aggBucket.Index,
|
||||
Alias: aggBucket.Alias,
|
||||
Meta: aggBucket.Meta,
|
||||
}
|
||||
|
||||
for _, series := range aggBucket.Series {
|
||||
@@ -766,7 +798,7 @@ func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, startMs,
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
filteredData := &qbtypes.TimeSeriesData{
|
||||
Aggregations: make([]*qbtypes.AggregationBucket, 0, len(tsData.Aggregations)),
|
||||
|
||||
@@ -92,6 +92,10 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
// This needs to include all fields that affect the query results
|
||||
parts := []string{q.queryType.StringValue()}
|
||||
|
||||
// A heatmap and a time series query can share every spec field and still
|
||||
// return different rows, so the request type has to separate their entries
|
||||
parts = append(parts, fmt.Sprintf("requestType=%s", q.kind.StringValue()))
|
||||
|
||||
// Add signal type
|
||||
parts = append(parts, fmt.Sprintf("signal=%s", q.spec.Signal.StringValue()))
|
||||
|
||||
@@ -130,6 +134,9 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
}
|
||||
part += ":" + route
|
||||
}
|
||||
if a.HeatmapBucketing != nil {
|
||||
part += ":" + fingerprintHeatmapBucketing(*a.HeatmapBucketing)
|
||||
}
|
||||
aggParts = append(aggParts, part)
|
||||
}
|
||||
}
|
||||
@@ -185,6 +192,16 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
// fingerprintHeatmapBucketing captures only what changes the rows ClickHouse
|
||||
// returns, which is why LogBucketsSpec.Scale is absent: coarsening it happens in
|
||||
// postprocessing, so every scale reads one cache entry.
|
||||
func fingerprintHeatmapBucketing(b qbtypes.HeatmapBucketing) string {
|
||||
if b.Kind == qbtypes.BucketsKindLinear {
|
||||
return fmt.Sprintf("%s:%v:%d", b.Kind.StringValue(), b.MaxValue, b.NumBuckets)
|
||||
}
|
||||
return b.Kind.StringValue()
|
||||
}
|
||||
|
||||
func fingerprintGroupByKey(gb qbtypes.GroupByKey) string {
|
||||
return fingerprintFieldKey(gb.TelemetryFieldKey)
|
||||
}
|
||||
@@ -412,7 +429,7 @@ func (q *builderQuery[T]) narrowWindowByTraceID(ctx context.Context, fromMS, toM
|
||||
func emptyResultFor(kind qbtypes.RequestType, queryName string) *qbtypes.Result {
|
||||
var value any
|
||||
switch kind {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
value = &qbtypes.TimeSeriesData{QueryName: queryName}
|
||||
case qbtypes.RequestTypeScalar:
|
||||
value = &qbtypes.ScalarData{QueryName: queryName}
|
||||
@@ -465,8 +482,9 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
|
||||
queryWindow := &qbtypes.TimeRange{From: q.fromMS, To: q.toMS}
|
||||
|
||||
kind := q.kind
|
||||
// all metric queries are time series then reduced if required
|
||||
if q.spec.Signal == telemetrytypes.SignalMetrics {
|
||||
// all metric queries are time series then reduced if required, except
|
||||
// heatmaps, whose statement returns a row per bucket rather than per point
|
||||
if q.spec.Signal == telemetrytypes.SignalMetrics && kind != qbtypes.RequestTypeHeatmap {
|
||||
kind = qbtypes.RequestTypeTimeSeries
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -120,6 +121,162 @@ func TestBuilderQueryFingerprintQueryType(t *testing.T) {
|
||||
assert.Empty(t, ai.Fingerprint())
|
||||
}
|
||||
|
||||
func TestBuilderQueryFingerprintHeatmapBucketing(t *testing.T) {
|
||||
coarseLogScale := 1
|
||||
|
||||
testCases := []struct {
|
||||
description string
|
||||
left *builderQuery[qbtypes.MetricAggregation]
|
||||
right *builderQuery[qbtypes.MetricAggregation]
|
||||
expectedEqual bool
|
||||
}{
|
||||
{
|
||||
// fingerprintHeatmapBucketing leaves LogScale out, so the two are
|
||||
// indistinguishable here by design
|
||||
description: "a coarser logScale reads the same cache entry",
|
||||
left: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLog, LogScale: qbtypes.MaxLogScale, NumBuckets: qbtypes.DefaultNumBuckets},
|
||||
}},
|
||||
},
|
||||
},
|
||||
right: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLog, LogScale: coarseLogScale, NumBuckets: qbtypes.DefaultNumBuckets},
|
||||
}},
|
||||
},
|
||||
},
|
||||
expectedEqual: true,
|
||||
},
|
||||
{
|
||||
description: "linear separates on maxValue",
|
||||
left: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLinear, MaxValue: 500, NumBuckets: 25},
|
||||
}},
|
||||
},
|
||||
},
|
||||
right: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLinear, MaxValue: 800, NumBuckets: 25},
|
||||
}},
|
||||
},
|
||||
},
|
||||
expectedEqual: false,
|
||||
},
|
||||
{
|
||||
description: "linear separates on numBuckets",
|
||||
left: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLinear, MaxValue: 500, NumBuckets: 25},
|
||||
}},
|
||||
},
|
||||
},
|
||||
right: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLinear, MaxValue: 500, NumBuckets: 40},
|
||||
}},
|
||||
},
|
||||
},
|
||||
expectedEqual: false,
|
||||
},
|
||||
{
|
||||
description: "linear and log are separate entries",
|
||||
left: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLinear, MaxValue: 500, NumBuckets: 25},
|
||||
}},
|
||||
},
|
||||
},
|
||||
right: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLog, LogScale: qbtypes.MaxLogScale, NumBuckets: qbtypes.DefaultNumBuckets},
|
||||
}},
|
||||
},
|
||||
},
|
||||
expectedEqual: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
if testCase.expectedEqual {
|
||||
assert.Equal(t, testCase.left.Fingerprint(), testCase.right.Fingerprint())
|
||||
return
|
||||
}
|
||||
assert.NotEqual(t, testCase.left.Fingerprint(), testCase.right.Fingerprint())
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a histogram folds in no bucket options at all", func(t *testing.T) {
|
||||
// resolveHeatmapBucketing leaves histograms nil, so bucketOptions sent
|
||||
// alongside one must not fragment its cache
|
||||
histogram := &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "signoz_latency",
|
||||
Type: metrictypes.HistogramType,
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
fingerprint := histogram.Fingerprint()
|
||||
assert.NotContains(t, fingerprint, qbtypes.BucketsKindLog.StringValue())
|
||||
assert.NotContains(t, fingerprint, qbtypes.BucketsKindLinear.StringValue())
|
||||
})
|
||||
}
|
||||
|
||||
func TestMakeBucketsOrder(t *testing.T) {
|
||||
// Test that makeBuckets returns buckets in reverse chronological order by default
|
||||
// Using milliseconds as input - need > 1 hour range to get multiple buckets
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
@@ -83,6 +84,8 @@ func consume(rows driver.Rows, kind qbtypes.RequestType, queryWindow *qbtypes.Ti
|
||||
payload, err = readAsTimeSeries(rows, queryWindow, step, queryName)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
payload, err = readAsScalar(rows, queryName)
|
||||
case qbtypes.RequestTypeHeatmap:
|
||||
payload, err = readAsHeatmap(rows, queryWindow, step, queryName)
|
||||
case qbtypes.RequestTypeRaw, qbtypes.RequestTypeTrace, qbtypes.RequestTypeRawStream:
|
||||
payload, err = readAsRaw(rows, queryName)
|
||||
// TODO: add support for other request types
|
||||
@@ -112,35 +115,6 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
|
||||
|
||||
stepMs := uint64(step.Milliseconds())
|
||||
|
||||
// Helper function to check if a timestamp represents a partial value
|
||||
isPartialValue := func(timestamp int64) bool {
|
||||
if stepMs == 0 || queryWindow == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
timestampMs := uint64(timestamp)
|
||||
|
||||
// For the first interval, check if query start is misaligned
|
||||
// The first complete interval starts at the first timestamp >= queryWindow.From that is aligned to step
|
||||
firstCompleteInterval := queryWindow.From
|
||||
if queryWindow.From%stepMs != 0 {
|
||||
// Round up to next step boundary
|
||||
firstCompleteInterval = ((queryWindow.From / stepMs) + 1) * stepMs
|
||||
}
|
||||
|
||||
// If timestamp is before the first complete interval, it's partial
|
||||
if timestampMs < firstCompleteInterval {
|
||||
return true
|
||||
}
|
||||
|
||||
// For the last interval, check if it would extend beyond query end
|
||||
if timestampMs+stepMs > queryWindow.To {
|
||||
return queryWindow.To%stepMs != 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Pre-allocate for labels based on column count
|
||||
lblValsCapacity := len(colNames) - 1 // -1 for timestamp
|
||||
if lblValsCapacity < 0 {
|
||||
@@ -271,7 +245,7 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
|
||||
series.Values = append(series.Values, &qbtypes.TimeSeriesValue{
|
||||
Timestamp: ts,
|
||||
Value: val,
|
||||
Partial: isPartialValue(ts),
|
||||
Partial: isPartialValue(ts, queryWindow, stepMs),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -315,6 +289,132 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
|
||||
}, nil
|
||||
}
|
||||
|
||||
func hasHeatmapBucketBounds(colNames []string) bool {
|
||||
var hasMin, hasMax bool
|
||||
for _, colName := range colNames {
|
||||
switch stripKeyAlias(colName) {
|
||||
case qbtypes.HeatmapBucketMinColumn:
|
||||
hasMin = true
|
||||
case qbtypes.HeatmapBucketMaxColumn:
|
||||
hasMax = true
|
||||
}
|
||||
}
|
||||
return hasMin && hasMax
|
||||
}
|
||||
|
||||
// readAsHeatmap folds one row per cell — (timestamp, group labels, bucket bounds, count) — into one series per group.
|
||||
func readAsHeatmap(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbtypes.Step, queryName string) (*qbtypes.TimeSeriesData, error) {
|
||||
colTypes := rows.ColumnTypes()
|
||||
colNames := rows.Columns()
|
||||
|
||||
if !hasHeatmapBucketBounds(colNames) {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"a heatmap needs a %q and a %q column to know the extent of each bucket",
|
||||
qbtypes.HeatmapBucketMinColumn, qbtypes.HeatmapBucketMaxColumn)
|
||||
}
|
||||
|
||||
slots := make([]any, len(colTypes))
|
||||
for i, ct := range colTypes {
|
||||
slots[i] = reflect.New(ct.ScanType()).Interface()
|
||||
}
|
||||
|
||||
stepMs := uint64(step.Milliseconds())
|
||||
|
||||
accumulator := newHeatmapAccumulator()
|
||||
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(slots...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
ts int64
|
||||
bounds bucketBounds
|
||||
count float64
|
||||
lblVals []string
|
||||
lblObjs []*qbtypes.Label
|
||||
)
|
||||
|
||||
for idx, ptr := range slots {
|
||||
name := stripKeyAlias(colNames[idx])
|
||||
value := derefValue(ptr)
|
||||
|
||||
if t, ok := value.(time.Time); ok {
|
||||
ts = t.UnixMilli()
|
||||
continue
|
||||
}
|
||||
|
||||
switch name {
|
||||
case qbtypes.HeatmapBucketMinColumn:
|
||||
bounds.Lower = numericAsFloat(value)
|
||||
case qbtypes.HeatmapBucketMaxColumn:
|
||||
bounds.Upper = numericAsFloat(value)
|
||||
default:
|
||||
if aggRe.MatchString(name) || slices.Contains(legacyReservedColumnTargetAliases, name) {
|
||||
count = numericAsFloat(value)
|
||||
continue
|
||||
}
|
||||
// a nullable label column comes back as a nil any, which would
|
||||
// otherwise key the series on the literal "<nil>"
|
||||
if value == nil {
|
||||
value = ""
|
||||
}
|
||||
lblVals = append(lblVals, fmt.Sprint(value))
|
||||
lblObjs = append(lblObjs, &qbtypes.Label{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: name},
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if ts == 0 || !isValidBucketBounds(bounds) || math.IsNaN(count) || math.IsInf(count, 0) {
|
||||
continue
|
||||
}
|
||||
sort.Strings(lblVals)
|
||||
labelsKey := strings.Join(lblVals, ",")
|
||||
|
||||
if err := accumulator.addCell(labelsKey, lblObjs, ts, bounds, count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return accumulator.foldSeries(queryWindow, stepMs, queryName)
|
||||
}
|
||||
|
||||
// isPartialValue reports whether the step interval starting at timestamp is only
|
||||
// partly covered by the query window, which happens when the window boundaries
|
||||
// are not step-aligned.
|
||||
func isPartialValue(timestamp int64, queryWindow *qbtypes.TimeRange, stepMs uint64) bool {
|
||||
if stepMs == 0 || queryWindow == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
timestampMs := uint64(timestamp)
|
||||
|
||||
// For the first interval, check if query start is misaligned
|
||||
// The first complete interval starts at the first timestamp >= queryWindow.From that is aligned to step
|
||||
firstCompleteInterval := queryWindow.From
|
||||
if queryWindow.From%stepMs != 0 {
|
||||
// Round up to next step boundary
|
||||
firstCompleteInterval = ((queryWindow.From / stepMs) + 1) * stepMs
|
||||
}
|
||||
|
||||
// If timestamp is before the first complete interval, it's partial
|
||||
if timestampMs < firstCompleteInterval {
|
||||
return true
|
||||
}
|
||||
|
||||
// For the last interval, check if it would extend beyond query end
|
||||
if timestampMs+stepMs > queryWindow.To {
|
||||
return queryWindow.To%stepMs != 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isNumericKind(t reflect.Type) bool {
|
||||
if t == nil {
|
||||
return false
|
||||
|
||||
191
pkg/querier/heatmap_accumulator.go
Normal file
191
pkg/querier/heatmap_accumulator.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
// bucketBounds holds the values in (Lower, Upper]. Only Upper reaches the
|
||||
// response; Lower is what says whether two rows bucketed the same way.
|
||||
type bucketBounds struct {
|
||||
Lower float64
|
||||
Upper float64
|
||||
}
|
||||
|
||||
// heatmapColumn maps a bucket to the count in it, holding one timestamp's cells.
|
||||
// Keyed rather than indexed by bucket because the axis is only known once every
|
||||
// cell has been seen.
|
||||
type heatmapColumn map[bucketBounds]float64
|
||||
|
||||
func isValidBucketBounds(bounds bucketBounds) bool {
|
||||
return !math.IsNaN(bounds.Lower) && !math.IsNaN(bounds.Upper) &&
|
||||
!math.IsInf(bounds.Upper, -1) && bounds.Lower < bounds.Upper
|
||||
}
|
||||
|
||||
type heatmapAxisBucketDetails struct {
|
||||
bounds bucketBounds
|
||||
labels []*qbtypes.Label
|
||||
ts int64
|
||||
}
|
||||
|
||||
func (b heatmapAxisBucketDetails) describe() string {
|
||||
at := time.UnixMilli(b.ts).UTC().Format(time.RFC3339)
|
||||
if len(b.labels) == 0 {
|
||||
return at
|
||||
}
|
||||
|
||||
pairs := make([]string, 0, len(b.labels))
|
||||
for _, label := range b.labels {
|
||||
pairs = append(pairs, fmt.Sprintf("%s=%v", label.Key.Name, label.Value))
|
||||
}
|
||||
return fmt.Sprintf("%s at %s", strings.Join(pairs, ", "), at)
|
||||
}
|
||||
|
||||
// heatmapSeries accumulates one group's columns while the rows are read.
|
||||
type heatmapSeries struct {
|
||||
labels []*qbtypes.Label
|
||||
timestampToColumn map[int64]heatmapColumn
|
||||
}
|
||||
|
||||
// heatmapAccumulator collects cells from either reader and folds them into one
|
||||
// series per group.
|
||||
type heatmapAccumulator struct {
|
||||
keyToSeries map[string]*heatmapSeries
|
||||
seriesOrder []string
|
||||
upperBoundToBucket map[float64]heatmapAxisBucketDetails
|
||||
}
|
||||
|
||||
func newHeatmapAccumulator() *heatmapAccumulator {
|
||||
return &heatmapAccumulator{
|
||||
keyToSeries: map[string]*heatmapSeries{},
|
||||
upperBoundToBucket: map[float64]heatmapAxisBucketDetails{},
|
||||
}
|
||||
}
|
||||
|
||||
// addCell files one cell under the group labelsKey identifies, keeping the
|
||||
// labels from the first cell seen for it. A response carrying upper bounds
|
||||
// alone cannot express two rows cutting the same bucket differently, so the
|
||||
// second of them is rejected here.
|
||||
func (a *heatmapAccumulator) addCell(labelsKey string, lbls []*qbtypes.Label, ts int64, bounds bucketBounds, count float64) error {
|
||||
bucketToAdd := heatmapAxisBucketDetails{bounds: bounds, labels: lbls, ts: ts}
|
||||
|
||||
if seenBucket, isUpperBoundSeenBefore := a.upperBoundToBucket[bounds.Upper]; isUpperBoundSeenBefore && seenBucket.bounds.Lower != bounds.Lower {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"the bucket ending at %v starts at %v for %s and at %v for %s",
|
||||
bounds.Upper, seenBucket.bounds.Lower, seenBucket.describe(), bounds.Lower, bucketToAdd.describe()).
|
||||
WithAdditional("A heatmap draws one set of buckets, so every row has to report the same ones")
|
||||
}
|
||||
a.upperBoundToBucket[bounds.Upper] = bucketToAdd
|
||||
|
||||
series, found := a.keyToSeries[labelsKey]
|
||||
if !found {
|
||||
series = &heatmapSeries{labels: lbls, timestampToColumn: map[int64]heatmapColumn{}}
|
||||
a.keyToSeries[labelsKey] = series
|
||||
a.seriesOrder = append(a.seriesOrder, labelsKey)
|
||||
}
|
||||
if series.timestampToColumn[ts] == nil {
|
||||
series.timestampToColumn[ts] = heatmapColumn{}
|
||||
}
|
||||
series.timestampToColumn[ts][bounds] += count
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveBucketAxis lists the upper bounds the counts are indexed against. A
|
||||
// gap goes on the axis as its own empty bucket, or the bucket above it would
|
||||
// widen to cover a range nothing bucketed.
|
||||
func (a *heatmapAccumulator) resolveBucketAxis() ([]float64, error) {
|
||||
upperBounds := make([]float64, 0, len(a.upperBoundToBucket))
|
||||
for upperBound := range a.upperBoundToBucket {
|
||||
if !math.IsInf(upperBound, 1) {
|
||||
upperBounds = append(upperBounds, upperBound)
|
||||
}
|
||||
}
|
||||
slices.Sort(upperBounds)
|
||||
|
||||
axis := make([]float64, 0, 2*len(upperBounds))
|
||||
for index, upperBound := range upperBounds {
|
||||
currentBucket := a.upperBoundToBucket[upperBound]
|
||||
if index > 0 {
|
||||
previousBucket := a.upperBoundToBucket[upperBounds[index-1]]
|
||||
switch {
|
||||
case currentBucket.bounds.Lower < previousBucket.bounds.Upper:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"the bucket %v to %v for %s covers values already in the bucket %v to %v for %s",
|
||||
currentBucket.bounds.Lower, currentBucket.bounds.Upper, currentBucket.describe(),
|
||||
previousBucket.bounds.Lower, previousBucket.bounds.Upper, previousBucket.describe()).
|
||||
WithAdditional("A heatmap draws one set of buckets, so every row has to report the same ones")
|
||||
case currentBucket.bounds.Lower > previousBucket.bounds.Upper:
|
||||
axis = append(axis, currentBucket.bounds.Lower)
|
||||
}
|
||||
}
|
||||
axis = append(axis, upperBound)
|
||||
}
|
||||
|
||||
return axis, nil
|
||||
}
|
||||
|
||||
// foldSeries turns the collected cells into one series per group, in the order
|
||||
// the groups first appeared.
|
||||
func (a *heatmapAccumulator) foldSeries(queryWindow *qbtypes.TimeRange, stepMs uint64, queryName string) (*qbtypes.TimeSeriesData, error) {
|
||||
if len(a.seriesOrder) == 0 {
|
||||
return &qbtypes.TimeSeriesData{QueryName: queryName}, nil
|
||||
}
|
||||
|
||||
upperBounds, err := a.resolveBucketAxis()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
upperBoundToIndex := make(map[float64]int, len(upperBounds)+1)
|
||||
for index, upperBound := range upperBounds {
|
||||
upperBoundToIndex[upperBound] = index
|
||||
}
|
||||
// the index past the last upper bound is where the +Inf overflow lands
|
||||
upperBoundToIndex[math.Inf(1)] = len(upperBounds)
|
||||
|
||||
bucket := &qbtypes.AggregationBucket{
|
||||
Index: 0,
|
||||
Alias: "__result_0",
|
||||
Meta: qbtypes.AggregationMeta{Buckets: upperBounds},
|
||||
Series: make([]*qbtypes.TimeSeries, 0, len(a.seriesOrder)),
|
||||
}
|
||||
|
||||
for _, labelsKey := range a.seriesOrder {
|
||||
accumulated := a.keyToSeries[labelsKey]
|
||||
|
||||
timestamps := make([]int64, 0, len(accumulated.timestampToColumn))
|
||||
for ts := range accumulated.timestampToColumn {
|
||||
timestamps = append(timestamps, ts)
|
||||
}
|
||||
slices.Sort(timestamps)
|
||||
|
||||
series := &qbtypes.TimeSeries{
|
||||
Labels: accumulated.labels,
|
||||
Values: make([]*qbtypes.TimeSeriesValue, 0, len(timestamps)),
|
||||
}
|
||||
for _, ts := range timestamps {
|
||||
values := make([]float64, len(upperBounds)+1)
|
||||
for bounds, count := range accumulated.timestampToColumn[ts] {
|
||||
values[upperBoundToIndex[bounds.Upper]] += count
|
||||
}
|
||||
series.Values = append(series.Values, &qbtypes.TimeSeriesValue{
|
||||
Timestamp: ts,
|
||||
Values: values,
|
||||
Partial: isPartialValue(ts, queryWindow, stepMs),
|
||||
})
|
||||
}
|
||||
bucket.Series = append(bucket.Series, series)
|
||||
}
|
||||
|
||||
return &qbtypes.TimeSeriesData{
|
||||
QueryName: queryName,
|
||||
Aggregations: []*qbtypes.AggregationBucket{bucket},
|
||||
}, nil
|
||||
}
|
||||
101
pkg/querier/heatmap_test.go
Normal file
101
pkg/querier/heatmap_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMergeTimeSeriesResultsUnionsHeatmapAxes(t *testing.T) {
|
||||
// a log axis holds whichever bands the data reached, so a wide cached range
|
||||
// and a narrow fresh one routinely disagree on which bands exist
|
||||
cached := &qbtypes.TimeSeriesData{
|
||||
QueryName: "A",
|
||||
Aggregations: []*qbtypes.AggregationBucket{{
|
||||
Index: 0,
|
||||
Meta: qbtypes.AggregationMeta{Buckets: []float64{1, 4, 16}},
|
||||
Series: []*qbtypes.TimeSeries{{
|
||||
Labels: []*qbtypes.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host.name"}, Value: "node-1"}},
|
||||
Values: []*qbtypes.TimeSeriesValue{{Timestamp: 1710000000000, Values: []float64{1, 2, 3, 4}}},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
fresh := []*qbtypes.Result{{
|
||||
Value: &qbtypes.TimeSeriesData{
|
||||
QueryName: "A",
|
||||
Aggregations: []*qbtypes.AggregationBucket{{
|
||||
Index: 0,
|
||||
Meta: qbtypes.AggregationMeta{Buckets: []float64{2, 4}},
|
||||
Series: []*qbtypes.TimeSeries{{
|
||||
Labels: []*qbtypes.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host.name"}, Value: "node-1"}},
|
||||
Values: []*qbtypes.TimeSeriesValue{{Timestamp: 1710000060000, Values: []float64{5, 6, 7}}},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}}
|
||||
|
||||
merged := (&querier{}).mergeTimeSeriesResults(cached, fresh)
|
||||
|
||||
require.Len(t, merged.Aggregations, 1)
|
||||
aggBucket := merged.Aggregations[0]
|
||||
assert.Equal(t, []float64{1, 2, 4, 16}, aggBucket.Meta.Buckets)
|
||||
|
||||
require.Len(t, aggBucket.Series, 1)
|
||||
require.Len(t, aggBucket.Series[0].Values, 2)
|
||||
// the cached 16 band survives even though the fresh range never reached it
|
||||
assert.Equal(t, []float64{1, 0, 2, 3, 4}, aggBucket.Series[0].Values[0].Values)
|
||||
// and the fresh 2 band survives even though the cached range never had it
|
||||
assert.Equal(t, []float64{0, 5, 6, 0, 7}, aggBucket.Series[0].Values[1].Values)
|
||||
}
|
||||
|
||||
func TestTrimResultToFluxBoundaryKeepsTheHeatmapAxis(t *testing.T) {
|
||||
cache := &bucketCache{logger: instrumentationtest.New().Logger()}
|
||||
|
||||
result := &qbtypes.Result{
|
||||
Type: qbtypes.RequestTypeHeatmap,
|
||||
Value: &qbtypes.TimeSeriesData{
|
||||
Aggregations: []*qbtypes.AggregationBucket{{
|
||||
Index: 0,
|
||||
Alias: "__result_0",
|
||||
Meta: qbtypes.AggregationMeta{Unit: "By", Buckets: []float64{1, 2, 4}},
|
||||
Series: []*qbtypes.TimeSeries{{
|
||||
Values: []*qbtypes.TimeSeriesValue{
|
||||
{Timestamp: 1710000000000, Values: []float64{1, 2, 3, 4}},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
trimmed := cache.trimResultToFluxBoundary(result, 1710000060000)
|
||||
|
||||
tsData, ok := trimmed.Value.(*qbtypes.TimeSeriesData)
|
||||
require.True(t, ok)
|
||||
require.Len(t, tsData.Aggregations, 1)
|
||||
|
||||
// the counts are positional against the axis, so a cached bucket that lost
|
||||
// Meta.Buckets would be realigned from an empty axis and collapse into the
|
||||
// overflow slot on the way back out
|
||||
aggBucket := tsData.Aggregations[0]
|
||||
assert.Equal(t, []float64{1, 2, 4}, aggBucket.Meta.Buckets)
|
||||
assert.Equal(t, "By", aggBucket.Meta.Unit)
|
||||
assert.Equal(t, "__result_0", aggBucket.Alias)
|
||||
}
|
||||
|
||||
func TestRealignFromAnEmptyAxisCollapsesIntoTheOverflow(t *testing.T) {
|
||||
// pins the behaviour the trim bug exposed: with no axis to read the counts
|
||||
// against, everything lands in the overflow slot
|
||||
aggBucket := &qbtypes.AggregationBucket{
|
||||
Series: []*qbtypes.TimeSeries{{
|
||||
Values: []*qbtypes.TimeSeriesValue{{Timestamp: 1710000000000, Values: []float64{7, 8, 9, 10}}},
|
||||
}},
|
||||
}
|
||||
|
||||
aggBucket.ReindexValuesToNewUpperBounds([]float64{1, 2, 4})
|
||||
|
||||
assert.Equal(t, []float64{0, 0, 0, 7}, aggBucket.Series[0].Values[0].Values)
|
||||
}
|
||||
@@ -195,6 +195,16 @@ func postProcessBuilderQuery[T any](
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveHeatmapBucketAxis brings the bucket axis to the resolution the caller
|
||||
// asked for. Downscaling runs first so AddHeatmapBucketsWithNoCounts adds them
|
||||
// at that resolution rather than the finer one ClickHouse bucketed at.
|
||||
func resolveHeatmapBucketAxis(tsData *qbtypes.TimeSeriesData, bucketing qbtypes.HeatmapBucketing) {
|
||||
if bucketing.Kind == qbtypes.BucketsKindLog {
|
||||
qbtypes.DownscaleHeatmapResolution(tsData, bucketing.LogScale)
|
||||
}
|
||||
qbtypes.AddHeatmapBucketsWithNoCounts(tsData, bucketing)
|
||||
}
|
||||
|
||||
// postProcessMetricQuery applies postprocessing to a metric query result.
|
||||
func postProcessMetricQuery(
|
||||
q *querier,
|
||||
@@ -216,6 +226,12 @@ func postProcessMetricQuery(
|
||||
}
|
||||
}
|
||||
|
||||
if req.RequestType == qbtypes.RequestTypeHeatmap && config.HeatmapBucketing != nil {
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
resolveHeatmapBucketAxis(tsData, *config.HeatmapBucketing)
|
||||
}
|
||||
}
|
||||
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Order)
|
||||
|
||||
if len(query.Functions) > 0 {
|
||||
@@ -342,6 +358,19 @@ func (q *querier) applyFormulas(ctx context.Context, results map[string]*qbtypes
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
|
||||
results[name] = result
|
||||
}
|
||||
case qbtypes.RequestTypeHeatmap:
|
||||
// The queries a formula reads were run as time series, so what
|
||||
// arrives here is one value per group per timestamp.
|
||||
result := q.processTimeSeriesFormula(ctx, results, formula, req)
|
||||
if result != nil {
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
bucketing := formula.BucketOptions.ToHeatmapBucketing()
|
||||
bucketFormulaOutputAsHeatmap(tsData, bucketing)
|
||||
resolveHeatmapBucketAxis(tsData, bucketing)
|
||||
}
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
|
||||
results[name] = result
|
||||
}
|
||||
case qbtypes.RequestTypeScalar:
|
||||
result := q.processScalarFormula(ctx, results, formula, req)
|
||||
// For scalar results, apply limit by processScalarFormula itself since it needs to be applied before converting back to scalar format
|
||||
@@ -410,6 +439,89 @@ func (q *querier) processTimeSeriesFormula(
|
||||
return result
|
||||
}
|
||||
|
||||
func bucketFormulaOutputAsHeatmap(tsData *qbtypes.TimeSeriesData, bucketing qbtypes.HeatmapBucketing) {
|
||||
// A formula is one expression, so processTimeSeriesFormula gives it one
|
||||
// aggregation.
|
||||
if tsData == nil || len(tsData.Aggregations) == 0 || tsData.Aggregations[0] == nil {
|
||||
return
|
||||
}
|
||||
aggBucket := tsData.Aggregations[0]
|
||||
|
||||
calculateUpperBound := calculateLogValueUpperBound
|
||||
if bucketing.Kind == qbtypes.BucketsKindLinear {
|
||||
calculateUpperBound = func(value float64) float64 {
|
||||
return calculateLinearValueUpperBound(bucketing, value)
|
||||
}
|
||||
}
|
||||
|
||||
// +Inf is the open-above overflow rather than an upper bound of its own, and
|
||||
// a NaN value has no bucket at all, so neither goes on the axis.
|
||||
upperBounds := []float64{}
|
||||
for _, series := range aggBucket.Series {
|
||||
for _, point := range series.Values {
|
||||
upperBound := calculateUpperBound(point.Value)
|
||||
if !math.IsNaN(upperBound) && !math.IsInf(upperBound, 0) {
|
||||
upperBounds = append(upperBounds, upperBound)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(upperBounds)
|
||||
upperBounds = slices.Compact(upperBounds)
|
||||
|
||||
upperBoundToIndex := make(map[float64]int, len(upperBounds))
|
||||
for index, upperBound := range upperBounds {
|
||||
upperBoundToIndex[upperBound] = index
|
||||
}
|
||||
|
||||
overflowIndex := len(upperBounds)
|
||||
for _, series := range aggBucket.Series {
|
||||
for _, point := range series.Values {
|
||||
upperBound := calculateUpperBound(point.Value)
|
||||
point.Values = make([]float64, overflowIndex+1)
|
||||
point.Value = 0
|
||||
switch {
|
||||
case math.IsNaN(upperBound):
|
||||
case math.IsInf(upperBound, 1):
|
||||
point.Values[overflowIndex] = 1
|
||||
default:
|
||||
point.Values[upperBoundToIndex[upperBound]] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
aggBucket.Meta.Buckets = upperBounds
|
||||
}
|
||||
|
||||
// calculateLinearValueUpperBound and calculateLogValueUpperBound are the Go side
|
||||
// of what renderLinearUpperBoundExpr and renderLogUpperBoundExpr emit, and have
|
||||
// to stay identical to them: a formula heatmap and a metric heatmap that
|
||||
// disagreed here would put their counts in different buckets.
|
||||
func calculateLinearValueUpperBound(bucketing qbtypes.HeatmapBucketing, value float64) float64 {
|
||||
if value > bucketing.MaxValue {
|
||||
return math.Inf(1)
|
||||
}
|
||||
numBuckets := float64(bucketing.NumBuckets)
|
||||
index := math.Min(math.Max(math.Ceil(value*numBuckets/bucketing.MaxValue), 1), numBuckets)
|
||||
return index * bucketing.MaxValue / numBuckets
|
||||
}
|
||||
|
||||
// Like renderLogUpperBoundExpr, this reads MaxLogScale rather than the requested
|
||||
// scale: ClickHouse buckets at the finest resolution and resolveHeatmapBucketAxis
|
||||
// folds the axis down afterwards.
|
||||
func calculateLogValueUpperBound(value float64) float64 {
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
if value <= qbtypes.MinLogUpperBound {
|
||||
return qbtypes.MinLogUpperBound
|
||||
}
|
||||
if value > qbtypes.MaxLogUpperBound {
|
||||
return math.Inf(1)
|
||||
}
|
||||
bucketsPerDoubling := math.Exp2(qbtypes.MaxLogScale)
|
||||
return math.Exp2(math.Ceil(math.Log2(value)*bucketsPerDoubling) / bucketsPerDoubling)
|
||||
}
|
||||
|
||||
func (q *querier) processScalarFormula(
|
||||
ctx context.Context,
|
||||
results map[string]*qbtypes.Result,
|
||||
@@ -494,7 +606,7 @@ func (q *querier) processScalarFormula(
|
||||
bucket := &qbtypes.AggregationBucket{
|
||||
Index: aggIdx,
|
||||
Alias: scalarData.Columns[colIdx].Name,
|
||||
Meta: scalarData.Columns[colIdx].Meta,
|
||||
Meta: qbtypes.AggregationMeta{Unit: scalarData.Columns[colIdx].Meta.Unit},
|
||||
Series: make([]*qbtypes.TimeSeries, 0),
|
||||
}
|
||||
|
||||
@@ -667,13 +779,14 @@ func convertTimeSeriesDataToScalar(tsData *qbtypes.TimeSeriesData, queryName str
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("__result_%d", agg.Index)
|
||||
}
|
||||
columns = append(columns, &qbtypes.ColumnDescriptor{
|
||||
column := &qbtypes.ColumnDescriptor{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: name},
|
||||
QueryName: queryName,
|
||||
AggregationIndex: int64(agg.Index),
|
||||
Meta: agg.Meta,
|
||||
Type: qbtypes.ColumnTypeAggregation,
|
||||
})
|
||||
}
|
||||
column.Meta.Unit = agg.Meta.Unit
|
||||
columns = append(columns, column)
|
||||
}
|
||||
|
||||
// Build rows.
|
||||
|
||||
@@ -50,7 +50,7 @@ func (q *querier) QueryRangePreview(
|
||||
env := []qbtypes.QueryEnvelope{req.CompositeQuery.Queries[idx]}
|
||||
ps.Warnings = append(ps.Warnings, q.adjustStepInterval(env, req.Start, req.End)...)
|
||||
|
||||
missingMetricQueries, metricWarnings, mErr := q.resolveMetricMetadata(ctx, orgID, env, req.Start, req.End)
|
||||
missingMetricQueries, metricWarnings, mErr := q.resolveMetricMetadata(ctx, orgID, env, req.Start, req.End, req.RequestType)
|
||||
if mErr != nil {
|
||||
// Report this query's error but keep previewing the rest.
|
||||
ps.Error = mErr
|
||||
|
||||
149
pkg/querier/promql_heatmap.go
Normal file
149
pkg/querier/promql_heatmap.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// promHistogramBucketLabel is the label a classic histogram carries its
|
||||
// cumulative upper bound on.
|
||||
const promHistogramBucketLabel = "le"
|
||||
|
||||
// cumulativeColumn maps a bucket's upper bound to the cumulative count at it.
|
||||
// Differencing turns it into the per-band counts a heatmapColumn holds.
|
||||
type cumulativeColumn map[float64]float64
|
||||
|
||||
// promHeatmapGroup assembles one group across the several matrix series its `le`
|
||||
// values arrive as, since differencing needs all of them.
|
||||
type promHeatmapGroup struct {
|
||||
labels []*qbv5.Label
|
||||
labelsKey string
|
||||
cumulative map[int64]cumulativeColumn
|
||||
}
|
||||
|
||||
// foldMatrixAsHeatmap folds a matrix of one cumulative series per (group, `le`)
|
||||
// into one series per group whose points hold a count per band.
|
||||
func foldMatrixAsHeatmap(matrix promql.Matrix, queryWindow *qbv5.TimeRange, stepMs uint64, queryName string) (*qbv5.TimeSeriesData, error) {
|
||||
groups, groupOrder := collectCumulativeGroups(matrix)
|
||||
|
||||
// An empty matrix is only ever the window having no data, but series that
|
||||
// all lack `le` say the expression itself cannot draw a heatmap.
|
||||
if len(matrix) > 0 && len(groups) == 0 {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"the query returned no `le` labels to build a bucket axis from").
|
||||
WithAdditional("Keep `le` through the aggregation, e.g. sum by (le) (rate(metric_bucket[5m]))")
|
||||
}
|
||||
|
||||
accumulator := newHeatmapAccumulator()
|
||||
for _, labelsKey := range groupOrder {
|
||||
if err := groups[labelsKey].addDifferencedCells(accumulator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return accumulator.foldSeries(queryWindow, stepMs, queryName)
|
||||
}
|
||||
|
||||
// collectCumulativeGroups reads the matrix into one group per label set. A series
|
||||
// without `le` has no band to sit in, so an expression that dropped the label
|
||||
// draws nothing.
|
||||
func collectCumulativeGroups(matrix promql.Matrix) (groups map[string]*promHeatmapGroup, groupOrder []string) {
|
||||
groups = map[string]*promHeatmapGroup{}
|
||||
|
||||
for _, promSeries := range matrix {
|
||||
upperBound, ok := extractBucketUpperBound(promSeries.Metric)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
lbls, labelsKey := extractHeatmapGroup(promSeries.Metric)
|
||||
group, ok := groups[labelsKey]
|
||||
if !ok {
|
||||
group = &promHeatmapGroup{labels: lbls, labelsKey: labelsKey, cumulative: map[int64]cumulativeColumn{}}
|
||||
groups[labelsKey] = group
|
||||
groupOrder = append(groupOrder, labelsKey)
|
||||
}
|
||||
|
||||
for _, point := range promSeries.Floats {
|
||||
if math.IsNaN(point.F) || math.IsInf(point.F, 0) {
|
||||
continue
|
||||
}
|
||||
if group.cumulative[point.T] == nil {
|
||||
group.cumulative[point.T] = cumulativeColumn{}
|
||||
}
|
||||
group.cumulative[point.T][upperBound] = point.F
|
||||
}
|
||||
}
|
||||
|
||||
return groups, groupOrder
|
||||
}
|
||||
|
||||
func extractBucketUpperBound(metric labels.Labels) (float64, bool) {
|
||||
raw := metric.Get(promHistogramBucketLabel)
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
upperBound, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil || math.IsNaN(upperBound) || math.IsInf(upperBound, -1) {
|
||||
return 0, false
|
||||
}
|
||||
return upperBound, true
|
||||
}
|
||||
|
||||
// extractHeatmapGroup returns a series' group labels — everything but `le`.
|
||||
func extractHeatmapGroup(metric labels.Labels) ([]*qbv5.Label, string) {
|
||||
lbls := make([]*qbv5.Label, 0, metric.Len())
|
||||
pairs := make([]string, 0, metric.Len())
|
||||
|
||||
metric.Range(func(l labels.Label) {
|
||||
if l.Name == promHistogramBucketLabel || excludePromLabel(l.Name) {
|
||||
return
|
||||
}
|
||||
lbls = append(lbls, &qbv5.Label{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: l.Name},
|
||||
Value: l.Value,
|
||||
})
|
||||
pairs = append(pairs, fmt.Sprintf("%s=%s", l.Name, l.Value))
|
||||
})
|
||||
|
||||
sort.Strings(pairs)
|
||||
return lbls, strings.Join(pairs, ",")
|
||||
}
|
||||
|
||||
// each cell is its upper bound's cumulative count minus the one below it, and
|
||||
// runs from that lower `le` up to its own. Nothing bounds the lowest one below:
|
||||
// a classic histogram counts negative observations in it too.
|
||||
func (g *promHeatmapGroup) addDifferencedCells(accumulator *heatmapAccumulator) error {
|
||||
for ts, cumulative := range g.cumulative {
|
||||
upperBounds := make([]float64, 0, len(cumulative))
|
||||
for upperBound := range cumulative {
|
||||
upperBounds = append(upperBounds, upperBound)
|
||||
}
|
||||
slices.Sort(upperBounds)
|
||||
|
||||
previousCount := float64(0)
|
||||
previousBound := math.Inf(-1)
|
||||
for _, upperBound := range upperBounds {
|
||||
bounds := bucketBounds{Lower: previousBound, Upper: upperBound}
|
||||
if err := accumulator.addCell(g.labelsKey, g.labels, ts, bounds, math.Max(cumulative[upperBound]-previousCount, 0)); err != nil {
|
||||
return err
|
||||
}
|
||||
previousCount = cumulative[upperBound]
|
||||
previousBound = upperBound
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
88
pkg/querier/promql_heatmap_test.go
Normal file
88
pkg/querier/promql_heatmap_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
|
||||
qbv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The cache key is the fingerprint alone, so two request types over one
|
||||
// expression must not produce the same one — a time series payload served to a
|
||||
// heatmap request has no axis and reads back as a single collapsed band.
|
||||
func TestFingerprintSeparatesHeatmapFromTimeSeries(t *testing.T) {
|
||||
fingerprintFor := func(requestType qbv5.RequestType) string {
|
||||
q := &promqlQuery{
|
||||
logger: slog.New(slog.DiscardHandler),
|
||||
query: qbv5.PromQuery{Name: "A", Query: "sum by (le) (increase(signoz_latency_bucket[5m]))", Step: qbv5.Step{Duration: time.Minute}},
|
||||
tr: qbv5.TimeRange{From: 1710000000000, To: 1710003600000},
|
||||
requestType: requestType,
|
||||
}
|
||||
return q.Fingerprint()
|
||||
}
|
||||
|
||||
heatmap := fingerprintFor(qbv5.RequestTypeHeatmap)
|
||||
timeSeries := fingerprintFor(qbv5.RequestTypeTimeSeries)
|
||||
|
||||
assert.NotEmpty(t, heatmap, "a heatmap decomposes into time buckets like a time series")
|
||||
assert.NotEqual(t, timeSeries, heatmap)
|
||||
assert.Empty(t, fingerprintFor(qbv5.RequestTypeScalar), "a scalar result is its window's last point")
|
||||
}
|
||||
|
||||
func TestFoldMatrixAsHeatmapClampsADecreasingCumulativeCount(t *testing.T) {
|
||||
at := int64(1710000000000)
|
||||
|
||||
matrix := promql.Matrix{
|
||||
{
|
||||
Metric: labels.FromStrings("le", "5"),
|
||||
Floats: []promql.FPoint{{T: at, F: 10}},
|
||||
},
|
||||
{
|
||||
Metric: labels.FromStrings("le", "10"),
|
||||
Floats: []promql.FPoint{{T: at, F: 4}},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := foldMatrixAsHeatmap(matrix, &qbv5.TimeRange{From: 1710000000000, To: 1710000060000}, uint64(time.Minute.Milliseconds()), "A")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, data.Aggregations, 1)
|
||||
|
||||
// a cumulative count that went backwards would difference to -6
|
||||
assert.Equal(t, []float64{10, 0, 0}, data.Aggregations[0].Series[0].Values[0].Values)
|
||||
}
|
||||
|
||||
func TestFoldMatrixAsHeatmapWidensTheBandOverAMissingUpperBound(t *testing.T) {
|
||||
at := int64(1710000000000)
|
||||
|
||||
matrix := promql.Matrix{
|
||||
{
|
||||
Metric: labels.FromStrings("le", "5"),
|
||||
Floats: []promql.FPoint{{T: at, F: 3}},
|
||||
},
|
||||
{
|
||||
Metric: labels.FromStrings("le", "10"),
|
||||
Floats: []promql.FPoint{{T: at, F: math.NaN()}},
|
||||
},
|
||||
{
|
||||
Metric: labels.FromStrings("le", "20"),
|
||||
Floats: []promql.FPoint{{T: at, F: 30}},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := foldMatrixAsHeatmap(matrix, &qbv5.TimeRange{From: 1710000000000, To: 1710000060000}, uint64(time.Minute.Milliseconds()), "A")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, data.Aggregations, 1)
|
||||
|
||||
aggregation := data.Aggregations[0]
|
||||
// 10 carried nothing to difference against, so it is not on the axis at all
|
||||
// and 20 differences against 5, holding what (5,10] and (10,20] would split
|
||||
assert.Equal(t, []float64{5, 20}, aggregation.Meta.Buckets)
|
||||
assert.Equal(t, []float64{3, 27, 0}, aggregation.Series[0].Values[0].Values)
|
||||
}
|
||||
@@ -155,7 +155,10 @@ func (q *promqlQuery) Fingerprint() string {
|
||||
if q.opts.serve != nil {
|
||||
return ""
|
||||
}
|
||||
if q.requestType != qbv5.RequestTypeTimeSeries {
|
||||
|
||||
switch q.requestType {
|
||||
case qbv5.RequestTypeTimeSeries, qbv5.RequestTypeHeatmap:
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -166,6 +169,8 @@ func (q *promqlQuery) Fingerprint() string {
|
||||
}
|
||||
parts := []string{
|
||||
"promql",
|
||||
// one expression returns a different shape per request type
|
||||
fmt.Sprintf("requestType=%s", q.requestType.StringValue()),
|
||||
query,
|
||||
q.query.Step.String(),
|
||||
}
|
||||
@@ -369,7 +374,7 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return q.toResult(matrix, nil, began, &statsMu, &rowsScanned, &bytesScanned), nil
|
||||
return q.toResult(matrix, nil, began, &statsMu, &rowsScanned, &bytesScanned)
|
||||
}
|
||||
|
||||
// When the serving provider has the RangeExecutor capability
|
||||
@@ -385,7 +390,7 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
return nil, err
|
||||
}
|
||||
if served {
|
||||
return q.toResult(matrix, nil, began, &statsMu, &rowsScanned, &bytesScanned), nil
|
||||
return q.toResult(matrix, nil, began, &statsMu, &rowsScanned, &bytesScanned)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,28 +451,60 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
}
|
||||
|
||||
warnings, _ := res.Warnings.AsStrings(query, 10, 0)
|
||||
return q.toResult(matrix, warnings, began, &statsMu, &rowsScanned, &bytesScanned), nil
|
||||
return q.toResult(matrix, warnings, began, &statsMu, &rowsScanned, &bytesScanned)
|
||||
}
|
||||
|
||||
// toResult converts an evaluated matrix into the v5 result shape, attaching
|
||||
// the ClickHouse scan stats accumulated during evaluation.
|
||||
func (q *promqlQuery) toResult(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) *qbv5.Result {
|
||||
// Hide only known SigNoz storage keys: label names are user data and may
|
||||
// legitimately start with "__" (e.g. __address__), so a blanket dunder
|
||||
// strip mangles user labelsets. The __scope./__resource. prefixes cover
|
||||
// every exporter version's keys.
|
||||
excludeLabel := func(labelName string) bool {
|
||||
return labelName == "__temporality__" ||
|
||||
strings.HasPrefix(labelName, "__scope.") ||
|
||||
strings.HasPrefix(labelName, "__resource.")
|
||||
// excludePromLabel hides only known SigNoz storage keys: label names are user
|
||||
// data and may legitimately start with "__" (e.g. __address__), so a blanket
|
||||
// dunder strip mangles user labelsets. The __scope./__resource. prefixes cover
|
||||
// every exporter version's keys.
|
||||
func excludePromLabel(labelName string) bool {
|
||||
return labelName == "__temporality__" ||
|
||||
strings.HasPrefix(labelName, "__scope.") ||
|
||||
strings.HasPrefix(labelName, "__resource.")
|
||||
}
|
||||
|
||||
// collectExecStats snapshots the scan counters a query accumulated. Callers take
|
||||
// it at the point they are done with the matrix, so the duration covers the
|
||||
// shaping they did.
|
||||
func collectExecStats(began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) qbv5.ExecStats {
|
||||
statsMu.Lock()
|
||||
defer statsMu.Unlock()
|
||||
return qbv5.ExecStats{
|
||||
RowsScanned: *rowsScanned,
|
||||
BytesScanned: *bytesScanned,
|
||||
DurationMS: uint64(time.Since(began).Milliseconds()),
|
||||
}
|
||||
}
|
||||
|
||||
func (q *promqlQuery) toResult(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) (*qbv5.Result, error) {
|
||||
if q.requestType == qbv5.RequestTypeHeatmap {
|
||||
return q.toResultForHeatmap(matrix, warnings, began, statsMu, rowsScanned, bytesScanned)
|
||||
}
|
||||
return q.toResultForTimeSeriesAndScalar(matrix, warnings, began, statsMu, rowsScanned, bytesScanned), nil
|
||||
}
|
||||
|
||||
func (q *promqlQuery) toResultForHeatmap(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) (*qbv5.Result, error) {
|
||||
tsData, err := foldMatrixAsHeatmap(matrix, &q.tr, uint64(q.query.Step.Milliseconds()), q.query.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &qbv5.Result{
|
||||
Type: q.requestType,
|
||||
Value: tsData,
|
||||
Warnings: warnings,
|
||||
Stats: collectExecStats(began, statsMu, rowsScanned, bytesScanned),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *promqlQuery) toResultForTimeSeriesAndScalar(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) *qbv5.Result {
|
||||
var series []*qbv5.TimeSeries
|
||||
for _, v := range matrix {
|
||||
var s qbv5.TimeSeries
|
||||
lbls := make([]*qbv5.Label, 0, v.Metric.Len())
|
||||
v.Metric.Range(func(l labels.Label) {
|
||||
if excludeLabel(l.Name) {
|
||||
if excludePromLabel(l.Name) {
|
||||
return
|
||||
}
|
||||
lbls = append(lbls, &qbv5.Label{
|
||||
@@ -495,13 +532,7 @@ func (q *promqlQuery) toResult(matrix promql.Matrix, warnings []string, began ti
|
||||
series = append(series, &s)
|
||||
}
|
||||
|
||||
statsMu.Lock()
|
||||
stats := qbv5.ExecStats{
|
||||
RowsScanned: *rowsScanned,
|
||||
BytesScanned: *bytesScanned,
|
||||
DurationMS: uint64(time.Since(began).Milliseconds()),
|
||||
}
|
||||
statsMu.Unlock()
|
||||
stats := collectExecStats(began, statsMu, rowsScanned, bytesScanned)
|
||||
|
||||
tsData := &qbv5.TimeSeriesData{QueryName: q.query.Name}
|
||||
// No bucket at all when nothing survived: a bucket holding no series reads
|
||||
|
||||
@@ -495,7 +495,8 @@ func TestToResultDropsNonFiniteValues(t *testing.T) {
|
||||
|
||||
var mu sync.Mutex
|
||||
var rows, bytes uint64
|
||||
result := q.toResult(matrix, nil, time.Now(), &mu, &rows, &bytes)
|
||||
result, err := q.toResult(matrix, nil, time.Now(), &mu, &rows, &bytes)
|
||||
require.NoError(t, err)
|
||||
|
||||
tsData, ok := result.Value.(*qbv5.TimeSeriesData)
|
||||
require.True(t, ok)
|
||||
@@ -526,7 +527,9 @@ func TestToResultDropsSeriesAndBucketLeftEmpty(t *testing.T) {
|
||||
|
||||
var mu sync.Mutex
|
||||
var rows, bytes uint64
|
||||
tsData, ok := q.toResult(matrix, nil, time.Now(), &mu, &rows, &bytes).Value.(*qbv5.TimeSeriesData)
|
||||
result, err := q.toResult(matrix, nil, time.Now(), &mu, &rows, &bytes)
|
||||
require.NoError(t, err)
|
||||
tsData, ok := result.Value.(*qbv5.TimeSeriesData)
|
||||
require.True(t, ok)
|
||||
require.Len(t, tsData.Aggregations, 1)
|
||||
require.Len(t, tsData.Aggregations[0].Series, 1, "the all-NaN series is gone")
|
||||
@@ -535,7 +538,9 @@ func TestToResultDropsSeriesAndBucketLeftEmpty(t *testing.T) {
|
||||
allNaN := promql.Matrix{
|
||||
{Metric: labels.FromStrings("job_name", "idleJob"), Floats: []promql.FPoint{{T: 1000, F: math.NaN()}}},
|
||||
}
|
||||
tsData, ok = q.toResult(allNaN, nil, time.Now(), &mu, &rows, &bytes).Value.(*qbv5.TimeSeriesData)
|
||||
result, err = q.toResult(allNaN, nil, time.Now(), &mu, &rows, &bytes)
|
||||
require.NoError(t, err)
|
||||
tsData, ok = result.Value.(*qbv5.TimeSeriesData)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, tsData.Aggregations)
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
// We need to set if it is unspecified or adjust it if value is not within recommended range
|
||||
intervalWarnings := q.adjustStepInterval(req.CompositeQuery.Queries, req.Start, req.End)
|
||||
|
||||
missingMetricQueries, metricWarnings, err := q.resolveMetricMetadata(ctx, orgID, req.CompositeQuery.Queries, req.Start, req.End)
|
||||
missingMetricQueries, metricWarnings, err := q.resolveMetricMetadata(ctx, orgID, req.CompositeQuery.Queries, req.Start, req.End, req.RequestType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -177,7 +177,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
preseededResults := make(map[string]any)
|
||||
for _, name := range missingMetricQueries {
|
||||
switch req.RequestType {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
preseededResults[name] = &qbtypes.TimeSeriesData{QueryName: name}
|
||||
case qbtypes.RequestTypeScalar:
|
||||
preseededResults[name] = &qbtypes.ScalarData{QueryName: name}
|
||||
@@ -334,15 +334,22 @@ func (q *querier) buildQueries(
|
||||
if missingMetricQuerySet[spec.Name] {
|
||||
continue
|
||||
}
|
||||
requestType := req.RequestType
|
||||
if requestType == qbtypes.RequestTypeHeatmap && 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
|
||||
}
|
||||
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)
|
||||
var bq *builderQuery[qbtypes.MetricAggregation]
|
||||
|
||||
if spec.Source == telemetrytypes.SourceMeter {
|
||||
event.Source = telemetrytypes.SourceMeter.StringValue()
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.meterStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.meterStmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, builderConfig{})
|
||||
} else {
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.metricStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.metricStmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, builderConfig{})
|
||||
}
|
||||
|
||||
queries[spec.Name] = bq
|
||||
@@ -415,7 +422,7 @@ func (q *querier) populateQBEvent(event *qbtypes.QBEvent, queries []qbtypes.Quer
|
||||
// resolved: never-seen metrics and dormant metrics (seen but no data in
|
||||
// the query window).
|
||||
// - err: Internal when a metadata fetch fails.
|
||||
func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID, queries []qbtypes.QueryEnvelope, start, end uint64) (missingMetricQueries []string, metricWarnings []string, err error) {
|
||||
func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID, queries []qbtypes.QueryEnvelope, start, end uint64, requestType qbtypes.RequestType) (missingMetricQueries []string, metricWarnings []string, err error) {
|
||||
metricNames := make([]string, 0)
|
||||
for idx := range queries {
|
||||
if queries[idx].Type != qbtypes.QueryTypeBuilder {
|
||||
@@ -473,6 +480,13 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
|
||||
if err := spec.Aggregations[i].ValidateForTypeAndTemporality(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Only the enabled query is used to render the heatmap, so bucket
|
||||
// options are only applied to the enabled query.
|
||||
if requestType == qbtypes.RequestTypeHeatmap && !spec.Disabled {
|
||||
if err := spec.Aggregations[i].VerifyAndApplyBucketOptions(spec.BucketOptions); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if reducedMetricsSet[spec.Aggregations[i].MetricName] {
|
||||
spec.Aggregations[i].Reduced = true
|
||||
}
|
||||
@@ -679,7 +693,7 @@ func (q *querier) run(
|
||||
if val, ok := result.Value.(*qbtypes.RawData); ok && val != nil {
|
||||
return len(val.Rows) != 0
|
||||
}
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
if val, ok := result.Value.(*qbtypes.TimeSeriesData); ok && val != nil {
|
||||
if len(val.Aggregations) != 0 {
|
||||
anyNonEmpty := false
|
||||
@@ -1000,7 +1014,7 @@ func (q *querier) mergeResults(cached *qbtypes.Result, fresh []*qbtypes.Result)
|
||||
|
||||
// Merge all fresh results including the first one
|
||||
switch merged.Type {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
// Pass nil as cached value to ensure proper merging of all fresh results
|
||||
merged.Value = q.mergeTimeSeriesResults(nil, fresh)
|
||||
}
|
||||
@@ -1023,7 +1037,7 @@ func (q *querier) mergeResults(cached *qbtypes.Result, fresh []*qbtypes.Result)
|
||||
}
|
||||
|
||||
switch merged.Type {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
merged.Value = q.mergeTimeSeriesResults(cached.Value.(*qbtypes.TimeSeriesData), fresh)
|
||||
}
|
||||
|
||||
@@ -1044,6 +1058,16 @@ func (q *querier) mergeResults(cached *qbtypes.Result, fresh []*qbtypes.Result)
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeBucketUpperBounds(cachedValue *qbtypes.TimeSeriesData, freshResults []*qbtypes.Result) map[int][]float64 {
|
||||
upperBoundSources := make([]*qbtypes.TimeSeriesData, 0, len(freshResults)+1)
|
||||
upperBoundSources = append(upperBoundSources, cachedValue)
|
||||
for _, result := range freshResults {
|
||||
freshTS, _ := result.Value.(*qbtypes.TimeSeriesData)
|
||||
upperBoundSources = append(upperBoundSources, freshTS)
|
||||
}
|
||||
return qbtypes.MergeBucketUpperBounds(upperBoundSources...)
|
||||
}
|
||||
|
||||
// mergeTimeSeriesResults merges time series data.
|
||||
func (q *querier) mergeTimeSeriesResults(cachedValue *qbtypes.TimeSeriesData, freshResults []*qbtypes.Result) *qbtypes.TimeSeriesData {
|
||||
|
||||
@@ -1052,12 +1076,15 @@ func (q *querier) mergeTimeSeriesResults(cachedValue *qbtypes.TimeSeriesData, fr
|
||||
// Map to store aggregation bucket metadata
|
||||
bucketMetadata := make(map[int]*qbtypes.AggregationBucket)
|
||||
|
||||
mergedUpperBounds := mergeBucketUpperBounds(cachedValue, freshResults)
|
||||
|
||||
// Process cached data if available
|
||||
if cachedValue != nil && cachedValue.Aggregations != nil {
|
||||
for _, aggBucket := range cachedValue.Aggregations {
|
||||
if seriesMap[aggBucket.Index] == nil {
|
||||
seriesMap[aggBucket.Index] = make(map[string]*qbtypes.TimeSeries)
|
||||
}
|
||||
aggBucket.ReindexValuesToNewUpperBounds(mergedUpperBounds[aggBucket.Index])
|
||||
if bucketMetadata[aggBucket.Index] == nil {
|
||||
bucketMetadata[aggBucket.Index] = aggBucket
|
||||
}
|
||||
@@ -1109,6 +1136,7 @@ func (q *querier) mergeTimeSeriesResults(cachedValue *qbtypes.TimeSeriesData, fr
|
||||
}
|
||||
|
||||
for _, aggBucket := range freshTS.Aggregations {
|
||||
aggBucket.ReindexValuesToNewUpperBounds(mergedUpperBounds[aggBucket.Index])
|
||||
for _, series := range aggBucket.Series {
|
||||
key := qbtypes.GetUniqueSeriesKey(series.Labels)
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ func (b *meterQueryStatementBuilder) buildPipelineStatement(
|
||||
}
|
||||
|
||||
// final SELECT
|
||||
return b.metricsStatementBuilder.BuildFinalSelect(cteFragments, cteArgs, query)
|
||||
return b.metricsStatementBuilder.BuildFinalSelect(cteFragments, cteArgs, qbtypes.RequestTypeTimeSeries, query)
|
||||
}
|
||||
|
||||
func (b *meterQueryStatementBuilder) buildTemporalAggDeltaFastPath(
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestReducedStatementBuilder(t *testing.T) {
|
||||
name: "histogram_p99",
|
||||
query: reducedQuery("test.metric.bucket", metrictypes.HistogramType, metrictypes.Cumulative, metrictypes.TimeAggregationUnspecified, metrictypes.SpaceAggregationPercentile99),
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) UNION ALL SELECT * FROM (WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, sum(`sum`) / 300 AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points FINAL INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) ORDER BY ts SETTINGS do_not_merge_across_partitions_select_final = 1, optimize_move_to_prewhere_if_final = 1",
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) UNION ALL SELECT * FROM (WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, sum(`sum`) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points FINAL INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) ORDER BY ts SETTINGS do_not_merge_across_partitions_select_final = 1, optimize_move_to_prewhere_if_final = 1",
|
||||
Args: []any{"test.metric.bucket", uint64(1746921600000), uint64(1747172760000), "cumulative", "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), 0, "test.metric.bucket", uint64(1746997200000), uint64(1747172760000), "test.metric.bucket", uint64(1746999900000), uint64(1747172760000)},
|
||||
},
|
||||
},
|
||||
@@ -130,7 +130,7 @@ func TestReducedStatementBuilder(t *testing.T) {
|
||||
name: "histogram_p99_group_by",
|
||||
query: withGroupBy(reducedQuery("test.metric.bucket", metrictypes.HistogramType, metrictypes.Cumulative, metrictypes.TimeAggregationUnspecified, metrictypes.SpaceAggregationPercentile99), "service.name"),
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name`, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY `__GROUP_BY_KEY_0_service.name`, ts) UNION ALL SELECT * FROM (WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(`sum`) / 300 AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points FINAL INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY `__GROUP_BY_KEY_0_service.name`, ts) ORDER BY `__GROUP_BY_KEY_0_service.name`, ts SETTINGS do_not_merge_across_partitions_select_final = 1, optimize_move_to_prewhere_if_final = 1",
|
||||
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name`, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY `__GROUP_BY_KEY_0_service.name`, ts) UNION ALL SELECT * FROM (WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(`sum`) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points FINAL INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY `__GROUP_BY_KEY_0_service.name`, ts) ORDER BY `__GROUP_BY_KEY_0_service.name`, ts SETTINGS do_not_merge_across_partitions_select_final = 1, optimize_move_to_prewhere_if_final = 1",
|
||||
Args: []any{"test.metric.bucket", uint64(1746921600000), uint64(1747172760000), "cumulative", "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), 0, "test.metric.bucket", uint64(1746997200000), uint64(1747172760000), "test.metric.bucket", uint64(1746999900000), uint64(1747172760000)},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,10 +4,14 @@ 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"
|
||||
@@ -114,7 +118,7 @@ func (b *StatementBuilder) Build(
|
||||
orgID valuer.UUID,
|
||||
start uint64,
|
||||
end uint64,
|
||||
_ qbtypes.RequestType,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
@@ -126,13 +130,14 @@ func (b *StatementBuilder) Build(
|
||||
|
||||
start, end = querybuilder.AdjustedMetricTimeRange(start, end, uint64(query.StepInterval.Seconds()), query)
|
||||
|
||||
return b.buildPipelineStatement(ctx, orgID, start, end, query, keys, variables)
|
||||
return b.buildPipelineStatement(ctx, orgID, start, end, requestType, query, keys, variables)
|
||||
}
|
||||
|
||||
func (b *StatementBuilder) buildPipelineStatement(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
@@ -145,7 +150,7 @@ func (b *StatementBuilder) buildPipelineStatement(
|
||||
cteQuery := query
|
||||
if query.Aggregations[0].Type == metrictypes.HistogramType {
|
||||
query.GroupBy = slices.DeleteFunc(slices.Clone(query.GroupBy), isHistogramBucket)
|
||||
cteQuery = histogramCTEQuery(query)
|
||||
cteQuery = rewriteQueryForHistogramCTE(query)
|
||||
}
|
||||
|
||||
agg := cteQuery.Aggregations[0]
|
||||
@@ -217,7 +222,7 @@ func (b *StatementBuilder) buildPipelineStatement(
|
||||
}
|
||||
}
|
||||
|
||||
mainStmt, err := b.BuildFinalSelect(cteFragments, cteArgs, query)
|
||||
mainStmt, err := b.BuildFinalSelect(cteFragments, cteArgs, requestType, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -225,13 +230,27 @@ func (b *StatementBuilder) buildPipelineStatement(
|
||||
if reducedFragments == nil {
|
||||
return mainStmt, nil
|
||||
}
|
||||
reducedStmt, err := b.BuildFinalSelect(reducedFragments, reducedArgs, query)
|
||||
reducedStmt, err := b.BuildFinalSelect(reducedFragments, reducedArgs, requestType, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unionStatements(mainStmt, reducedStmt, query)
|
||||
}
|
||||
|
||||
func rewriteQueryForHistogramCTE(query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]) qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation] {
|
||||
query.GroupBy = append(slices.Clone(query.GroupBy), qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: histogramBucketKey},
|
||||
})
|
||||
|
||||
// The CTE yields one observation count per `le` per step. The space
|
||||
// aggregation the caller asked for is applied downstream, over the `le` array.
|
||||
query.Aggregations = slices.Clone(query.Aggregations)
|
||||
query.Aggregations[0].TimeAggregation = metrictypes.TimeAggregationIncrease
|
||||
query.Aggregations[0].SpaceAggregation = metrictypes.SpaceAggregationSum
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
func unionStatements(main, reduced *qbtypes.Statement, query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]) (*qbtypes.Statement, error) {
|
||||
orderBy := "ts"
|
||||
for i, g := range query.GroupBy {
|
||||
@@ -761,11 +780,9 @@ func (b *StatementBuilder) buildSpatialAggregationCTE(
|
||||
func (b *StatementBuilder) BuildFinalSelect(
|
||||
cteFragments []string,
|
||||
cteArgs [][]any,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
metricType := query.Aggregations[0].Type
|
||||
spaceAgg := query.Aggregations[0].SpaceAggregation
|
||||
|
||||
combined := querybuilder.CombineCTEs(cteFragments)
|
||||
|
||||
var args []any
|
||||
@@ -773,6 +790,22 @@ func (b *StatementBuilder) BuildFinalSelect(
|
||||
args = append(args, a...)
|
||||
}
|
||||
|
||||
if requestType == qbtypes.RequestTypeHeatmap {
|
||||
return buildHeatmapFinalSelect(combined, args, query)
|
||||
}
|
||||
return buildAggregationFinalSelect(combined, args, query)
|
||||
}
|
||||
|
||||
// buildAggregationFinalSelect reads __spatial_aggregation_cte as one value per
|
||||
// (group, timestamp), which is what every request type but heatmap wants.
|
||||
func buildAggregationFinalSelect(
|
||||
combined string,
|
||||
args []any,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
metricType := query.Aggregations[0].Type
|
||||
spaceAgg := query.Aggregations[0].SpaceAggregation
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
if metricType == metrictypes.HistogramType && spaceAgg.IsPercentile() {
|
||||
@@ -845,24 +878,150 @@ func (b *StatementBuilder) BuildFinalSelect(
|
||||
return &qbtypes.Statement{Query: combined + q, Args: append(args, a...)}, nil
|
||||
}
|
||||
|
||||
const histogramBucketKey = "le"
|
||||
const (
|
||||
histogramBucketKey = "le"
|
||||
|
||||
heatmapValueAlias = "__result_0"
|
||||
heatmapWindow = "__heatmap_window"
|
||||
)
|
||||
|
||||
func isHistogramBucket(k qbtypes.GroupByKey) bool { return k.Name == histogramBucketKey }
|
||||
|
||||
func histogramCTEQuery(query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]) qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation] {
|
||||
query.GroupBy = append(slices.Clone(query.GroupBy), qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: histogramBucketKey},
|
||||
})
|
||||
|
||||
query.Aggregations = slices.Clone(query.Aggregations)
|
||||
if query.Aggregations[0].SpaceAggregation.IsPercentile() {
|
||||
query.Aggregations[0].TimeAggregation = metrictypes.TimeAggregationRate
|
||||
} else {
|
||||
query.Aggregations[0].TimeAggregation = metrictypes.TimeAggregationIncrease
|
||||
// buildHeatmapFinalSelect turns __spatial_aggregation_cte into one row per
|
||||
// heatmap cell: (ts, group labels..., bucket upper bound, count).
|
||||
func buildHeatmapFinalSelect(
|
||||
combined string,
|
||||
args []any,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
if query.Aggregations[0].Type == metrictypes.HistogramType {
|
||||
return buildHistogramHeatmapFinalSelect(combined, args, query)
|
||||
}
|
||||
query.Aggregations[0].SpaceAggregation = metrictypes.SpaceAggregationSum
|
||||
return buildValueHeatmapFinalSelect(combined, args, query)
|
||||
}
|
||||
|
||||
return query
|
||||
// buildHistogramHeatmapFinalSelect differences the cumulative per-`le` counts in
|
||||
// __spatial_aggregation_cte into a count per bucket. A bucket runs from the `le`
|
||||
// below it up to its own, so the `le=+Inf` row reaches the reader as the
|
||||
// overflow and the lowest `le` as a bucket open below, which is where a
|
||||
// negative observation would have been counted.
|
||||
func buildHistogramHeatmapFinalSelect(
|
||||
combined string,
|
||||
args []any,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
groupAliases := GroupByAliases(query.GroupBy)
|
||||
partitionBy := append(append([]string{}, groupAliases...), "ts")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("ts")
|
||||
sb.SelectMore(groupAliases...)
|
||||
sb.SelectMore(fmt.Sprintf(
|
||||
"lagInFrame(toFloat64(%s), 1, toFloat64('-Inf')) OVER %s AS %s",
|
||||
histogramBucketKey, heatmapWindow, qbtypes.HeatmapBucketMinColumn,
|
||||
))
|
||||
sb.SelectMore(fmt.Sprintf("toFloat64(%s) AS %s", histogramBucketKey, qbtypes.HeatmapBucketMaxColumn))
|
||||
// a partial scrape can break monotonicity across `le`, and a negative cell
|
||||
// count has no meaning
|
||||
sb.SelectMore(fmt.Sprintf(
|
||||
"greatest(value - lagInFrame(value, 1, 0) OVER %s, 0) AS %s",
|
||||
heatmapWindow, heatmapValueAlias,
|
||||
))
|
||||
// sqlbuilder has no WINDOW clause; appending it to FROM lands it between FROM
|
||||
// and ORDER BY, since these statements carry no WHERE or GROUP BY
|
||||
sb.From(fmt.Sprintf(
|
||||
"__spatial_aggregation_cte WINDOW %s AS (PARTITION BY %s ORDER BY toFloat64(%s))",
|
||||
heatmapWindow, strings.Join(partitionBy, ", "), histogramBucketKey,
|
||||
))
|
||||
sb.OrderBy(groupAliases...)
|
||||
sb.OrderBy("ts", fmt.Sprintf("toFloat64(%s)", histogramBucketKey))
|
||||
|
||||
q, a := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return &qbtypes.Statement{Query: combined + q, Args: append(args, a...)}, nil
|
||||
}
|
||||
|
||||
// buildValueHeatmapFinalSelect places each spatially aggregated value in a
|
||||
// bucket of the requested axis. __spatial_aggregation_cte holds one row per
|
||||
// (group, timestamp), so every cell counts exactly one.
|
||||
func buildValueHeatmapFinalSelect(
|
||||
combined string,
|
||||
args []any,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
bucketMin, bucketMax, err := renderHeatmapBucketExprs(*query.Aggregations[0].HeatmapBucketing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groupAliases := GroupByAliases(query.GroupBy)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("ts")
|
||||
sb.SelectMore(groupAliases...)
|
||||
sb.SelectMore(fmt.Sprintf("%s AS %s", bucketMin, qbtypes.HeatmapBucketMinColumn))
|
||||
sb.SelectMore(fmt.Sprintf("%s AS %s", bucketMax, qbtypes.HeatmapBucketMaxColumn))
|
||||
sb.SelectMore(fmt.Sprintf("toFloat64(1) AS %s", heatmapValueAlias))
|
||||
sb.From("__spatial_aggregation_cte")
|
||||
sb.OrderBy(groupAliases...)
|
||||
sb.OrderBy("ts", qbtypes.HeatmapBucketMaxColumn)
|
||||
|
||||
q, a := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
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 {
|
||||
|
||||
@@ -212,7 +212,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(value)/30 AS value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'service.name') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.950) AS value FROM __spatial_aggregation_cte GROUP BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
|
||||
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(value) AS value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'service.name') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.950) AS value FROM __spatial_aggregation_cte GROUP BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
|
||||
Args: []any{"signoz_latency", uint64(1747936800000), uint64(1747983420000), "delta", "cartservice", "signoz_latency", uint64(1747947390000), uint64(1747983420000)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -250,7 +250,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(value)/30 AS value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'service.name') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.950) AS value FROM __spatial_aggregation_cte GROUP BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
|
||||
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(value) AS value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'service.name') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.950) AS value FROM __spatial_aggregation_cte GROUP BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
|
||||
Args: []any{"signoz_latency", uint64(1747936800000), uint64(1747983420000), "delta", "cartservice", "signoz_latency", uint64(1747947390000), uint64(1747983420000)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -284,6 +284,201 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_histogram_heatmap_counting_aggregations",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "signoz_latency.bucket",
|
||||
Type: metrictypes.HistogramType,
|
||||
Temporality: metrictypes.Delta,
|
||||
TimeAggregation: metrictypes.TimeAggregationIncrease,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationCount,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(value) AS value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, lagInFrame(toFloat64(le), 1, toFloat64('-Inf')) OVER __heatmap_window AS __bucket_min, toFloat64(le) AS __bucket_max, greatest(value - lagInFrame(value, 1, 0) OVER __heatmap_window, 0) AS __result_0 FROM __spatial_aggregation_cte WINDOW __heatmap_window AS (PARTITION BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY toFloat64(le)) ORDER BY `__GROUP_BY_KEY_0_service.name`, ts, toFloat64(le)",
|
||||
Args: []any{"signoz_latency.bucket", uint64(1747936800000), uint64(1747983420000), "delta", "signoz_latency.bucket", uint64(1747947360000), uint64(1747983420000)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
// a heatmap counts observations per `le`, so a histogram's aggregations
|
||||
// are rewritten to increase/count whatever was asked for — this builds
|
||||
// the same statement as test_histogram_heatmap_counting_aggregations
|
||||
name: "test_histogram_heatmap_overrules_requested_aggregations",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "signoz_latency.bucket",
|
||||
Type: metrictypes.HistogramType,
|
||||
Temporality: metrictypes.Delta,
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(value) AS value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, lagInFrame(toFloat64(le), 1, toFloat64('-Inf')) OVER __heatmap_window AS __bucket_min, toFloat64(le) AS __bucket_max, greatest(value - lagInFrame(value, 1, 0) OVER __heatmap_window, 0) AS __result_0 FROM __spatial_aggregation_cte WINDOW __heatmap_window AS (PARTITION BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY toFloat64(le)) ORDER BY `__GROUP_BY_KEY_0_service.name`, ts, toFloat64(le)",
|
||||
Args: []any{"signoz_latency.bucket", uint64(1747936800000), uint64(1747983420000), "delta", "signoz_latency.bucket", uint64(1747947360000), uint64(1747983420000)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_gauge_heatmap_log",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
Temporality: metrictypes.Unspecified,
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{
|
||||
Kind: qbtypes.BucketsKindLog,
|
||||
LogScale: qbtypes.MaxLogScale,
|
||||
NumBuckets: qbtypes.DefaultNumBuckets,
|
||||
},
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "host.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_host.name`, avg(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'host.name') AS `__GROUP_BY_KEY_0_host.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_host.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_host.name` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_host.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_host.name`) SELECT ts, `__GROUP_BY_KEY_0_host.name`, multiIf(value <= 0, toFloat64('-Inf'), value <= 2.3283064365386963e-10, toFloat64(0), value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(value) * 16) - 1) / 16)) AS __bucket_min, multiIf(value <= 0, toFloat64(0), value <= 2.3283064365386963e-10, 2.3283064365386963e-10, value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(value) * 16) / 16)) AS __bucket_max, toFloat64(1) AS __result_0 FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_host.name`, ts, __bucket_max",
|
||||
Args: []any{"system.memory.usage", uint64(1747936800000), uint64(1747983420000), "unspecified", "system.memory.usage", uint64(1747947360000), uint64(1747983420000), 0},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_gauge_heatmap_linear",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
Temporality: metrictypes.Unspecified,
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{
|
||||
Kind: qbtypes.BucketsKindLinear,
|
||||
MaxValue: 500,
|
||||
NumBuckets: 25,
|
||||
},
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "host.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_host.name`, avg(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'host.name') AS `__GROUP_BY_KEY_0_host.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_host.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_host.name` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_host.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_host.name`) SELECT ts, `__GROUP_BY_KEY_0_host.name`, multiIf(value <= 0, toFloat64('-Inf'), value > 500, toFloat64(500), (least(greatest(ceil(value * 25 / 500), 1), 25) - 1) * 500 / 25) AS __bucket_min, multiIf(value <= 0, toFloat64(0), value > 500, toFloat64('+Inf'), least(greatest(ceil(value * 25 / 500), 1), 25) * 500 / 25) AS __bucket_max, toFloat64(1) AS __result_0 FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_host.name`, ts, __bucket_max",
|
||||
Args: []any{"system.memory.usage", uint64(1747936800000), uint64(1747983420000), "unspecified", "system.memory.usage", uint64(1747947360000), uint64(1747983420000), 0},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
// cumulative keeps CanShortCircuitDelta false, so the counts reach the
|
||||
// bucket differencing through the temporal CTE rather than the delta
|
||||
// fast path
|
||||
name: "test_histogram_heatmap_cumulative",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "http_server_duration_bucket",
|
||||
Type: metrictypes.HistogramType,
|
||||
Temporality: metrictypes.Cumulative,
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name`, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, lagInFrame(toFloat64(le), 1, toFloat64('-Inf')) OVER __heatmap_window AS __bucket_min, toFloat64(le) AS __bucket_max, greatest(value - lagInFrame(value, 1, 0) OVER __heatmap_window, 0) AS __result_0 FROM __spatial_aggregation_cte WINDOW __heatmap_window AS (PARTITION BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY toFloat64(le)) ORDER BY `__GROUP_BY_KEY_0_service.name`, ts, toFloat64(le)",
|
||||
Args: []any{"http_server_duration_bucket", uint64(1747936800000), uint64(1747983420000), "cumulative", "http_server_duration_bucket", uint64(1747947300000), uint64(1747983420000), 0},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_sum_heatmap_cumulative",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "signoz_calls_total",
|
||||
Type: metrictypes.SumType,
|
||||
Temporality: metrictypes.Cumulative,
|
||||
TimeAggregation: metrictypes.TimeAggregationIncrease,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{
|
||||
Kind: qbtypes.BucketsKindLog,
|
||||
LogScale: qbtypes.MaxLogScale,
|
||||
NumBuckets: qbtypes.DefaultNumBuckets,
|
||||
},
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_service.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf(value <= 0, toFloat64('-Inf'), value <= 2.3283064365386963e-10, toFloat64(0), value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(value) * 16) - 1) / 16)) AS __bucket_min, multiIf(value <= 0, toFloat64(0), value <= 2.3283064365386963e-10, 2.3283064365386963e-10, value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(value) * 16) / 16)) AS __bucket_max, toFloat64(1) AS __result_0 FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts, __bucket_max",
|
||||
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", "signoz_calls_total", uint64(1747947300000), uint64(1747983420000), 0},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_gauge_avg_sum",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
@@ -342,7 +537,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name`, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.950) AS value FROM __spatial_aggregation_cte GROUP BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
|
||||
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name`, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.950) AS value FROM __spatial_aggregation_cte GROUP BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
|
||||
Args: []any{"http_server_duration_bucket", uint64(1747936800000), uint64(1747983420000), "cumulative", "http_server_duration_bucket", uint64(1747947360000), uint64(1747983420000), 0},
|
||||
},
|
||||
expectedErr: nil,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -524,6 +525,149 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeatmapPanelQueryKinds pins the panel allowlist to what validateHeatmap
|
||||
// accepts in querybuildertypesv5: everything but a trace operator.
|
||||
func TestHeatmapPanelQueryKinds(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
queryPluginKind string
|
||||
queryPluginSpec string
|
||||
expectedAllowed bool
|
||||
}{
|
||||
{
|
||||
description: "a metrics builder query is allowed",
|
||||
queryPluginKind: "signoz/BuilderQuery",
|
||||
queryPluginSpec: `{"name": "A", "signal": "metrics", "aggregations": [
|
||||
{"metricName": "http.server.request.duration", "timeAggregation": "increase", "spaceAggregation": "sum"}
|
||||
]}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
description: "a promql query is allowed",
|
||||
queryPluginKind: "signoz/PromQLQuery",
|
||||
queryPluginSpec: `{"name": "A", "query": "sum by (le) (increase(signoz_latency_bucket[5m]))"}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
description: "a clickhouse query is allowed",
|
||||
queryPluginKind: "signoz/ClickHouseSQL",
|
||||
queryPluginSpec: `{"name": "A", "query": "SELECT ts, bucket, value FROM cells"}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
description: "a formula is allowed",
|
||||
queryPluginKind: "signoz/Formula",
|
||||
queryPluginSpec: `{"name": "F1", "expression": "A / B"}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
description: "a composite query is allowed, since a formula needs its disabled inputs alongside it",
|
||||
queryPluginKind: "signoz/CompositeQuery",
|
||||
queryPluginSpec: `{"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "metrics", "disabled": true, "aggregations": [
|
||||
{"metricName": "http.server.request.duration", "timeAggregation": "increase", "spaceAggregation": "sum"}
|
||||
]}},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "A * 2"}}
|
||||
]}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
description: "a trace operator is refused",
|
||||
queryPluginKind: "signoz/TraceOperator",
|
||||
queryPluginSpec: `{"name": "T1", "expression": "A => B"}`,
|
||||
expectedAllowed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
data := fmt.Sprintf(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/HeatmapPanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "heatmap",
|
||||
"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 TestValidateHeatmapDashboard(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/perses_heatmap_panel.json")
|
||||
require.NoError(t, err, "reading example file")
|
||||
|
||||
spec, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &HeatmapPanelSpec{}, spec.Panels["p1"].Spec.Plugin.Spec)
|
||||
panelSpec := spec.Panels["p1"].Spec.Plugin.Spec.(*HeatmapPanelSpec)
|
||||
assert.Equal(t, "log", panelSpec.Axes.YScale.ValueOrDefault())
|
||||
assert.Equal(t, "ember", panelSpec.ChartAppearance.Colors.Palette.ValueOrDefault())
|
||||
assert.Equal(t, "sqrt", panelSpec.ChartAppearance.Colors.Scale.ValueOrDefault())
|
||||
assert.Equal(t, 8, panelSpec.ChartAppearance.Colors.Steps)
|
||||
|
||||
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.RequestTypeHeatmap, request.RequestType)
|
||||
|
||||
require.Len(t, request.CompositeQuery.Queries, 3)
|
||||
numerator, ok := request.CompositeQuery.Queries[0].Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
|
||||
require.True(t, ok, "expected a metrics builder query")
|
||||
assert.True(t, numerator.Disabled)
|
||||
require.NotNil(t, numerator.BucketOptions)
|
||||
require.IsType(t, qb.LogBucketsSpec{}, numerator.BucketOptions.Spec)
|
||||
assert.Equal(t, 4, *numerator.BucketOptions.Spec.(qb.LogBucketsSpec).Scale)
|
||||
|
||||
denominator, ok := request.CompositeQuery.Queries[1].Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
|
||||
require.True(t, ok, "expected a metrics builder query")
|
||||
assert.True(t, denominator.Disabled)
|
||||
require.NotNil(t, denominator.BucketOptions)
|
||||
require.IsType(t, qb.LinearBucketsSpec{}, denominator.BucketOptions.Spec)
|
||||
assert.Equal(t, float64(1000), denominator.BucketOptions.Spec.(qb.LinearBucketsSpec).MaxValue)
|
||||
|
||||
formula, ok := request.CompositeQuery.Queries[2].Spec.(qb.QueryBuilderFormula)
|
||||
require.True(t, ok, "expected a formula")
|
||||
require.NotNil(t, formula.BucketOptions)
|
||||
assert.Equal(t, qb.BucketsKindLog, formula.BucketOptions.Kind)
|
||||
require.IsType(t, qb.LogBucketsSpec{}, formula.BucketOptions.Spec)
|
||||
assert.Equal(t, 2, *formula.BucketOptions.Spec.(qb.LogBucketsSpec).Scale)
|
||||
|
||||
require.NoError(t, request.Validate(), "the request built from the panel is not a valid heatmap request")
|
||||
|
||||
// the panel read back out of storage draws the same heatmap
|
||||
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")
|
||||
rereadRequest, err := (&DashboardV2{Spec: *reread}).GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the stored panel's query failed")
|
||||
assert.Equal(t, request, rereadRequest)
|
||||
}
|
||||
|
||||
func TestInvalidateOneInvalidPanel(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
|
||||
@@ -35,6 +35,7 @@ func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,6 +66,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[TablePanelSpec]{Kind: string(PanelKindTable)},
|
||||
PanelPluginVariant[HistogramPanelSpec]{Kind: string(PanelKindHistogram)},
|
||||
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
|
||||
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +230,7 @@ var (
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -250,6 +253,7 @@ var (
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -173,10 +173,11 @@ const (
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindHeatmap}
|
||||
}
|
||||
|
||||
type TimeSeriesPanelSpec struct {
|
||||
@@ -237,6 +238,56 @@ type ListPanelSpec struct {
|
||||
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero" validate:"dive"`
|
||||
}
|
||||
|
||||
type HeatmapPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
Axes HeatmapAxes `json:"axes"`
|
||||
Legend Legend `json:"legend"`
|
||||
ChartAppearance HeatmapChartAppearance `json:"chartAppearance"`
|
||||
}
|
||||
|
||||
// HeatmapAxes carries only the Y scale. The shared Axes type models a value
|
||||
// axis with soft bounds, where a heatmap's Y axis is the bucket boundaries the
|
||||
// response already fixed.
|
||||
type HeatmapAxes struct {
|
||||
YScale HeatmapYScale `json:"yScale"`
|
||||
}
|
||||
|
||||
type HeatmapChartAppearance struct {
|
||||
Colors HeatmapColors `json:"colors"`
|
||||
}
|
||||
|
||||
type HeatmapColors struct {
|
||||
Mode HeatmapColorMode `json:"mode"`
|
||||
Palette HeatmapPalette `json:"palette"`
|
||||
Scale HeatmapColorScale `json:"scale"`
|
||||
Steps int `json:"steps" validate:"omitempty,min=2,max=128"`
|
||||
// MinCount and MaxCount clamp the colour scale; nil derives them from the
|
||||
// grid, 0 and the highest count in it.
|
||||
MinCount *float64 `json:"minCount"`
|
||||
MaxCount *float64 `json:"maxCount"`
|
||||
// Fill applies in opacity mode; empty means the selected group's legend colour.
|
||||
Fill string `json:"fill"`
|
||||
}
|
||||
|
||||
func (c *HeatmapColors) UnmarshalJSON(data []byte) error {
|
||||
type alias HeatmapColors
|
||||
var tmp alias
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid heatmap colors")
|
||||
}
|
||||
*c = HeatmapColors(tmp)
|
||||
return c.validate()
|
||||
}
|
||||
|
||||
func (c HeatmapColors) validate() error {
|
||||
if c.MinCount != nil && c.MaxCount != nil && *c.MinCount > *c.MaxCount {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
|
||||
"heatmap colors.minCount %v is greater than colors.maxCount %v", *c.MinCount, *c.MaxCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Panel common types
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -709,3 +760,168 @@ func (p *PrecisionOption) UnmarshalJSON(data []byte) error {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid precision option %q: must be `0`, `1`, `2`, `3`, `4`, or `full`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type HeatmapColorMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
HeatmapColorModePalette = HeatmapColorMode{valuer.NewString("palette")} // default
|
||||
HeatmapColorModeOpacity = HeatmapColorMode{valuer.NewString("opacity")}
|
||||
)
|
||||
|
||||
func (HeatmapColorMode) Enum() []any {
|
||||
return []any{HeatmapColorModePalette, HeatmapColorModeOpacity}
|
||||
}
|
||||
|
||||
func (m HeatmapColorMode) ValueOrDefault() string {
|
||||
if m.IsZero() {
|
||||
return HeatmapColorModePalette.StringValue()
|
||||
}
|
||||
return m.StringValue()
|
||||
}
|
||||
|
||||
func (m HeatmapColorMode) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(m.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (m *HeatmapColorMode) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid heatmap color mode: must be a string, one of `palette` or `opacity`")
|
||||
}
|
||||
mode := HeatmapColorMode{valuer.NewString(v)}
|
||||
switch mode {
|
||||
case HeatmapColorModePalette, HeatmapColorModeOpacity:
|
||||
*m = mode
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid heatmap color mode %q: must be `palette` or `opacity`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type HeatmapPalette struct{ valuer.String }
|
||||
|
||||
var (
|
||||
HeatmapPaletteIce = HeatmapPalette{valuer.NewString("ice")}
|
||||
HeatmapPaletteMoss = HeatmapPalette{valuer.NewString("moss")}
|
||||
HeatmapPaletteRust = HeatmapPalette{valuer.NewString("rust")}
|
||||
HeatmapPaletteGraphite = HeatmapPalette{valuer.NewString("graphite")}
|
||||
HeatmapPaletteEmber = HeatmapPalette{valuer.NewString("ember")}
|
||||
HeatmapPaletteLagoon = HeatmapPalette{valuer.NewString("lagoon")}
|
||||
HeatmapPaletteOrchid = HeatmapPalette{valuer.NewString("orchid")}
|
||||
HeatmapPaletteVerdant = HeatmapPalette{valuer.NewString("verdant")}
|
||||
HeatmapPaletteLava = HeatmapPalette{valuer.NewString("lava")} // default
|
||||
HeatmapPaletteBeacon = HeatmapPalette{valuer.NewString("beacon")}
|
||||
)
|
||||
|
||||
func (HeatmapPalette) Enum() []any {
|
||||
return []any{
|
||||
HeatmapPaletteIce, HeatmapPaletteMoss, HeatmapPaletteRust, HeatmapPaletteGraphite,
|
||||
HeatmapPaletteEmber, HeatmapPaletteLagoon, HeatmapPaletteOrchid, HeatmapPaletteVerdant,
|
||||
HeatmapPaletteLava, HeatmapPaletteBeacon,
|
||||
}
|
||||
}
|
||||
|
||||
func (p HeatmapPalette) ValueOrDefault() string {
|
||||
if p.IsZero() {
|
||||
return HeatmapPaletteLava.StringValue()
|
||||
}
|
||||
return p.StringValue()
|
||||
}
|
||||
|
||||
func (p HeatmapPalette) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(p.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (p *HeatmapPalette) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid heatmap palette: must be a string, one of `ice`, `moss`, `rust`, `graphite`, `ember`, `lagoon`, `orchid`, `verdant`, `lava`, or `beacon`")
|
||||
}
|
||||
palette := HeatmapPalette{valuer.NewString(v)}
|
||||
switch palette {
|
||||
case HeatmapPaletteIce, HeatmapPaletteMoss, HeatmapPaletteRust, HeatmapPaletteGraphite,
|
||||
HeatmapPaletteEmber, HeatmapPaletteLagoon, HeatmapPaletteOrchid, HeatmapPaletteVerdant,
|
||||
HeatmapPaletteLava, HeatmapPaletteBeacon:
|
||||
*p = palette
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid heatmap palette %q: must be `ice`, `moss`, `rust`, `graphite`, `ember`, `lagoon`, `orchid`, `verdant`, `lava`, or `beacon`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type HeatmapYScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
HeatmapYScaleAuto = HeatmapYScale{valuer.NewString("auto")} // default
|
||||
HeatmapYScaleLinear = HeatmapYScale{valuer.NewString("linear")}
|
||||
HeatmapYScaleLog = HeatmapYScale{valuer.NewString("log")}
|
||||
HeatmapYScaleSymlog = HeatmapYScale{valuer.NewString("symlog")}
|
||||
)
|
||||
|
||||
func (HeatmapYScale) Enum() []any {
|
||||
return []any{HeatmapYScaleAuto, HeatmapYScaleLinear, HeatmapYScaleLog, HeatmapYScaleSymlog}
|
||||
}
|
||||
|
||||
func (s HeatmapYScale) ValueOrDefault() string {
|
||||
if s.IsZero() {
|
||||
return HeatmapYScaleAuto.StringValue()
|
||||
}
|
||||
return s.StringValue()
|
||||
}
|
||||
|
||||
func (s HeatmapYScale) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(s.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (s *HeatmapYScale) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid heatmap y scale: must be a string, one of `auto`, `linear`, `log`, or `symlog`")
|
||||
}
|
||||
scale := HeatmapYScale{valuer.NewString(v)}
|
||||
switch scale {
|
||||
case HeatmapYScaleAuto, HeatmapYScaleLinear, HeatmapYScaleLog, HeatmapYScaleSymlog:
|
||||
*s = scale
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid heatmap y scale %q: must be `auto`, `linear`, `log`, or `symlog`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type HeatmapColorScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
HeatmapColorScaleLog = HeatmapColorScale{valuer.NewString("log")} // default
|
||||
HeatmapColorScaleSqrt = HeatmapColorScale{valuer.NewString("sqrt")}
|
||||
HeatmapColorScaleLinear = HeatmapColorScale{valuer.NewString("linear")}
|
||||
)
|
||||
|
||||
func (HeatmapColorScale) Enum() []any {
|
||||
return []any{HeatmapColorScaleLog, HeatmapColorScaleSqrt, HeatmapColorScaleLinear}
|
||||
}
|
||||
|
||||
func (s HeatmapColorScale) ValueOrDefault() string {
|
||||
if s.IsZero() {
|
||||
return HeatmapColorScaleLog.StringValue()
|
||||
}
|
||||
return s.StringValue()
|
||||
}
|
||||
|
||||
func (s HeatmapColorScale) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(s.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (s *HeatmapColorScale) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid heatmap color scale: must be a string, one of `log`, `sqrt`, or `linear`")
|
||||
}
|
||||
scale := HeatmapColorScale{valuer.NewString(v)}
|
||||
switch scale {
|
||||
case HeatmapColorScaleLog, HeatmapColorScaleSqrt, HeatmapColorScaleLinear:
|
||||
*s = scale
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid heatmap color scale %q: must be `log`, `sqrt`, or `linear`", v)
|
||||
}
|
||||
}
|
||||
|
||||
149
pkg/types/dashboardtypes/testdata/perses_heatmap_panel.json
vendored
Normal file
149
pkg/types/dashboardtypes/testdata/perses_heatmap_panel.json
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"display": {
|
||||
"name": "latency",
|
||||
"description": "how request duration is distributed"
|
||||
},
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "request duration",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/HeatmapPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time"
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "s",
|
||||
"decimalPrecision": "3"
|
||||
},
|
||||
"axes": {
|
||||
"yScale": "log"
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "table"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"colors": {
|
||||
"mode": "palette",
|
||||
"palette": "ember",
|
||||
"scale": "sqrt",
|
||||
"steps": 8,
|
||||
"minCount": 0,
|
||||
"maxCount": 500
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"queries": [
|
||||
{
|
||||
"kind": "heatmap",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/CompositeQuery",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"disabled": true,
|
||||
"stepInterval": 60,
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "http.server.request.duration",
|
||||
"timeAggregation": "increase",
|
||||
"spaceAggregation": "sum"
|
||||
}
|
||||
],
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name"
|
||||
}
|
||||
],
|
||||
"bucketOptions": {
|
||||
"kind": "log",
|
||||
"spec": {
|
||||
"scale": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "metrics",
|
||||
"disabled": true,
|
||||
"stepInterval": 60,
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "http.server.request.count",
|
||||
"timeAggregation": "increase",
|
||||
"spaceAggregation": "sum"
|
||||
}
|
||||
],
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name"
|
||||
}
|
||||
],
|
||||
"bucketOptions": {
|
||||
"kind": "linear",
|
||||
"spec": {
|
||||
"maxValue": 1000,
|
||||
"numBuckets": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_formula",
|
||||
"spec": {
|
||||
"name": "F1",
|
||||
"expression": "A / B",
|
||||
"bucketOptions": {
|
||||
"kind": "log",
|
||||
"spec": {
|
||||
"scale": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 12,
|
||||
"height": 8,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/p1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -540,6 +540,8 @@ type MetricAggregation struct {
|
||||
// reduce to operator for metric scalar requests
|
||||
ReduceTo ReduceTo `json:"reduceTo,omitzero"`
|
||||
|
||||
HeatmapBucketing *HeatmapBucketing `json:"-"`
|
||||
|
||||
Reduced bool `json:"-"`
|
||||
}
|
||||
|
||||
@@ -554,6 +556,10 @@ func (m MetricAggregation) Copy() MetricAggregation {
|
||||
valueFilterCopy := *m.ValueFilter
|
||||
c.ValueFilter = &valueFilterCopy
|
||||
}
|
||||
if m.HeatmapBucketing != nil {
|
||||
bucketingCopy := *m.HeatmapBucketing
|
||||
c.HeatmapBucketing = &bucketingCopy
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,10 @@ type QueryBuilderQuery[T any] struct {
|
||||
// currently supported: []Aggregation, []MetricAggregation
|
||||
Aggregations []T `json:"aggregations,omitzero"`
|
||||
|
||||
// BucketOptions is the bucket axis to count this query's values into. Only a
|
||||
// heatmap request reads it, and only from the query it draws.
|
||||
BucketOptions *BucketOptions `json:"bucketOptions,omitempty"`
|
||||
|
||||
// disabled if true, the query will not be executed
|
||||
Disabled bool `json:"disabled"`
|
||||
|
||||
@@ -154,6 +158,11 @@ func (q QueryBuilderQuery[T]) Copy() QueryBuilderQuery[T] {
|
||||
c.Having = q.Having.Copy()
|
||||
}
|
||||
|
||||
if q.BucketOptions != nil {
|
||||
bucketOptionsCopy := *q.BucketOptions
|
||||
c.BucketOptions = &bucketOptionsCopy
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ type QueryBuilderFormula struct {
|
||||
// functions to apply to the formula result
|
||||
Functions []Function `json:"functions,omitzero"`
|
||||
|
||||
// BucketOptions is the bucket axis to count the formula's results into.
|
||||
// Only a heatmap request reads it, and only from the query it draws.
|
||||
BucketOptions *BucketOptions `json:"bucketOptions,omitempty"`
|
||||
|
||||
Legend string `json:"legend"`
|
||||
}
|
||||
|
||||
@@ -61,6 +65,11 @@ func (f QueryBuilderFormula) Copy() QueryBuilderFormula {
|
||||
c.Having = f.Having.Copy()
|
||||
}
|
||||
|
||||
if f.BucketOptions != nil {
|
||||
bucketOptionsCopy := *f.BucketOptions
|
||||
c.BucketOptions = &bucketOptionsCopy
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
308
pkg/types/querybuildertypes/querybuildertypesv5/heatmap.go
Normal file
308
pkg/types/querybuildertypes/querybuildertypesv5/heatmap.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
)
|
||||
|
||||
const (
|
||||
// A heatmap statement reports a row's bucket as (min, max] under these two
|
||||
// aliases, telling them apart from the single value column every other
|
||||
// aggregation returns. Only the upper bounds reach the response; the lower
|
||||
// ones are what says whether two rows bucketed the same way.
|
||||
HeatmapBucketMinColumn = "__bucket_min"
|
||||
HeatmapBucketMaxColumn = "__bucket_max"
|
||||
|
||||
DefaultNumBuckets = 60
|
||||
|
||||
// MaxLogScale is the resolution ClickHouse buckets every log heatmap at:
|
||||
// 2^MaxLogScale bands per doubling. It is both the default and the finest
|
||||
// available, since a coarser LogBucketsSpec.Scale folds down from it.
|
||||
MaxLogScale = 4
|
||||
// MinLogScale is one band per 16x, the coarsest axis worth rendering.
|
||||
MinLogScale = -4
|
||||
|
||||
// A positive value approaching zero runs its band index off to -inf, so
|
||||
// without a clamp one near-zero sample would stretch the axis by thousands
|
||||
// of bands once AddHeatmapBucketsWithNoCounts spans it.
|
||||
MinLogBandIndex = -512 // 2^-32, about 2.3e-10
|
||||
MaxLogBandIndex = 1024 // 2^64, about 1.8e19
|
||||
)
|
||||
|
||||
// MinLogUpperBound and MaxLogUpperBound are the ends the log axis is clamped
|
||||
// to. They do not vary with the requested scale.
|
||||
var (
|
||||
MinLogUpperBound = math.Exp2(float64(MinLogBandIndex) / math.Exp2(MaxLogScale))
|
||||
MaxLogUpperBound = math.Exp2(float64(MaxLogBandIndex) / math.Exp2(MaxLogScale))
|
||||
)
|
||||
|
||||
// HeatmapBucketing is the bucket axis a heatmap statement builds in ClickHouse,
|
||||
// resolved from BucketOptions once the metric type is known. It stays nil for
|
||||
// histograms, whose upper bounds come from their own `le` labels.
|
||||
type HeatmapBucketing struct {
|
||||
Kind BucketsKind
|
||||
// LogScale is the resolution the caller asked for. ClickHouse always buckets
|
||||
// at MaxLogScale, and postprocessing folds the axis down to this.
|
||||
LogScale int
|
||||
// MaxValue and NumBuckets are linear only.
|
||||
MaxValue float64
|
||||
NumBuckets int
|
||||
}
|
||||
|
||||
// ToHeatmapBucketing fills in what the caller left unset. An absent
|
||||
// BucketOptions resolves to the finest log axis, the one kind that needs nothing
|
||||
// from the caller.
|
||||
func (b *BucketOptions) ToHeatmapBucketing() HeatmapBucketing {
|
||||
resolved := HeatmapBucketing{
|
||||
Kind: BucketsKindLog,
|
||||
LogScale: MaxLogScale,
|
||||
NumBuckets: DefaultNumBuckets,
|
||||
}
|
||||
if b == nil {
|
||||
return resolved
|
||||
}
|
||||
|
||||
switch spec := b.Spec.(type) {
|
||||
case LinearBucketsSpec:
|
||||
resolved.Kind = BucketsKindLinear
|
||||
resolved.MaxValue = spec.MaxValue
|
||||
if spec.NumBuckets > 0 {
|
||||
resolved.NumBuckets = spec.NumBuckets
|
||||
}
|
||||
case LogBucketsSpec:
|
||||
if spec.Scale != nil {
|
||||
resolved.LogScale = *spec.Scale
|
||||
}
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// This cannot be called in validateHeatmap cuz metric type is resolved in querier.go.
|
||||
func (a *MetricAggregation) VerifyAndApplyBucketOptions(bucketOptions *BucketOptions) error {
|
||||
switch a.Type {
|
||||
case metrictypes.HistogramType:
|
||||
if bucketOptions != nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"bucketOptions are not supported for histogram metrics: %q takes its bucket axis from its own `le` labels, so nothing in the spec would be applied", a.MetricName)
|
||||
}
|
||||
a.HeatmapBucketing = nil
|
||||
return nil
|
||||
// A summary carries no upper bounds of its own either, and its samples reach
|
||||
// the final select the same way a gauge's do, so it buckets identically.
|
||||
case metrictypes.GaugeType, metrictypes.SumType, metrictypes.SummaryType:
|
||||
bucketing := bucketOptions.ToHeatmapBucketing()
|
||||
a.HeatmapBucketing = &bucketing
|
||||
return nil
|
||||
case metrictypes.UnspecifiedType:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"heatmaps need a metric whose type is known: no type is recorded for %q, so its bucket axis cannot be chosen", a.MetricName)
|
||||
case metrictypes.ExpHistogramType:
|
||||
return errors.Newf(errors.TypeUnsupported, errors.CodeUnsupported,
|
||||
"heatmaps are not supported for exponential histograms yet")
|
||||
default:
|
||||
return errors.Newf(errors.TypeUnsupported, errors.CodeUnsupported,
|
||||
"heatmaps are not supported for %s metrics", a.Type.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
func MergeBucketUpperBounds(tsData ...*TimeSeriesData) map[int][]float64 {
|
||||
upperBoundsByAggregation := map[int][]float64{}
|
||||
|
||||
for _, data := range tsData {
|
||||
if data == nil {
|
||||
continue
|
||||
}
|
||||
for _, aggBucket := range data.Aggregations {
|
||||
if len(aggBucket.Meta.Buckets) == 0 {
|
||||
continue
|
||||
}
|
||||
upperBoundsByAggregation[aggBucket.Index] = append(upperBoundsByAggregation[aggBucket.Index], aggBucket.Meta.Buckets...)
|
||||
}
|
||||
}
|
||||
|
||||
for index, upperBounds := range upperBoundsByAggregation {
|
||||
slices.Sort(upperBounds)
|
||||
upperBoundsByAggregation[index] = slices.Compact(upperBounds)
|
||||
}
|
||||
|
||||
return upperBoundsByAggregation
|
||||
}
|
||||
|
||||
// DownscaleHeatmapResolution folds the MaxLogScale axis ClickHouse buckets at
|
||||
// down to toScale, merging every 2^(MaxLogScale-toScale) adjacent bands into
|
||||
// one. The coarser upper bounds are a subset of the finer ones, so the fold is
|
||||
// exact.
|
||||
func DownscaleHeatmapResolution(tsData *TimeSeriesData, toScale int) {
|
||||
if tsData == nil || toScale >= MaxLogScale {
|
||||
return
|
||||
}
|
||||
for _, aggBucket := range tsData.Aggregations {
|
||||
downscaleHeatmapResolutionForAggregation(aggBucket, toScale)
|
||||
}
|
||||
}
|
||||
|
||||
func downscaleHeatmapResolutionForAggregation(aggBucket *AggregationBucket, toScale int) {
|
||||
if aggBucket == nil || len(aggBucket.Meta.Buckets) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
factor := int(math.Exp2(float64(MaxLogScale - toScale)))
|
||||
|
||||
// Merging is by index in the exponential mapping, not by position in
|
||||
// Meta.Buckets, which lists only the upper bounds some series reached.
|
||||
coarseUpperBounds := make([]float64, 0, len(aggBucket.Meta.Buckets))
|
||||
upperBoundToCoarseIndex := make(map[float64]int, len(aggBucket.Meta.Buckets))
|
||||
mergedInto := make([]int, len(aggBucket.Meta.Buckets))
|
||||
for index, upperBound := range aggBucket.Meta.Buckets {
|
||||
coarsened := coarsenUpperBound(upperBound, toScale, factor)
|
||||
coarseIndex, ok := upperBoundToCoarseIndex[coarsened]
|
||||
if !ok {
|
||||
coarseIndex = len(coarseUpperBounds)
|
||||
coarseUpperBounds = append(coarseUpperBounds, coarsened)
|
||||
upperBoundToCoarseIndex[coarsened] = coarseIndex
|
||||
}
|
||||
mergedInto[index] = coarseIndex
|
||||
}
|
||||
|
||||
overflowIndex := len(coarseUpperBounds)
|
||||
for _, series := range aggBucket.Series {
|
||||
for _, point := range series.Values {
|
||||
if len(point.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
coarseCounts := make([]float64, overflowIndex+1)
|
||||
for index, count := range point.Values {
|
||||
if index >= len(mergedInto) {
|
||||
coarseCounts[overflowIndex] += count
|
||||
continue
|
||||
}
|
||||
coarseCounts[mergedInto[index]] += count
|
||||
}
|
||||
point.Values = coarseCounts
|
||||
}
|
||||
}
|
||||
aggBucket.Meta.Buckets = coarseUpperBounds
|
||||
}
|
||||
|
||||
// coarsenUpperBound moves an upper bound from the MaxLogScale exponential axis
|
||||
// onto the toScale one. The zero band has no exponent to rescale and stays put.
|
||||
func coarsenUpperBound(upperBound float64, toScale, factor int) float64 {
|
||||
if upperBound <= 0 || math.IsInf(upperBound, 0) || math.IsNaN(upperBound) {
|
||||
return upperBound
|
||||
}
|
||||
index := int(math.Round(math.Log2(upperBound) * math.Exp2(MaxLogScale)))
|
||||
merged := int(math.Ceil(float64(index) / float64(factor)))
|
||||
return math.Exp2(float64(merged) / math.Exp2(float64(toScale)))
|
||||
}
|
||||
|
||||
// AddHeatmapBucketsWithNoCounts spans the range from the rung below the lowest
|
||||
// upper bound some series reached to the highest. Meta.Buckets leaves the ones
|
||||
// in between out entirely, so without this a gap renders with its two sides
|
||||
// touching and the lowest bucket reaches the floor of the axis.
|
||||
//
|
||||
// Only a value-derived axis can be spanned: its upper bounds come from an index
|
||||
// that is a pure function of the value, so the ones in between are known without
|
||||
// having seen them. Nothing says what sits between two `le` labels.
|
||||
func AddHeatmapBucketsWithNoCounts(tsData *TimeSeriesData, bucketing HeatmapBucketing) {
|
||||
if tsData == nil {
|
||||
return
|
||||
}
|
||||
for _, aggBucket := range tsData.Aggregations {
|
||||
addHeatmapBucketsWithNoCountsForAggregation(aggBucket, bucketing)
|
||||
}
|
||||
}
|
||||
|
||||
func addHeatmapBucketsWithNoCountsForAggregation(aggBucket *AggregationBucket, bucketing HeatmapBucketing) {
|
||||
if aggBucket == nil || len(aggBucket.Meta.Buckets) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// The zero bucket holds everything at or below zero. It has no index on either
|
||||
// axis and sits below every other upper bound, so it keeps index 0 and the
|
||||
// fill runs over the rest.
|
||||
offset := 0
|
||||
if aggBucket.Meta.Buckets[0] <= 0 {
|
||||
offset = 1
|
||||
}
|
||||
positive := aggBucket.Meta.Buckets[offset:]
|
||||
if len(positive) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Only finite upper bounds have an index, and the fill sizes a slice from
|
||||
// one. Nothing should put +Inf or NaN on the axis, but bail if it happens.
|
||||
indexes := make([]int, len(positive))
|
||||
for i, upperBound := range positive {
|
||||
if math.IsInf(upperBound, 0) || math.IsNaN(upperBound) {
|
||||
return
|
||||
}
|
||||
indexes[i] = bucketing.calculateIndexOfUpperBound(upperBound)
|
||||
}
|
||||
lowest, highest := slices.Min(indexes), slices.Max(indexes)
|
||||
|
||||
// The lowest bucket reached needs the rung under it on the axis, or its own
|
||||
// lower bound goes unstated and it reads as holding everything below. That
|
||||
// one rung is enough: everything under it merges into the single leading
|
||||
// bucket, which no series reached.
|
||||
from := lowest - 1
|
||||
if offset == 1 && bucketing.calculateUpperBoundAtIndex(from) <= aggBucket.Meta.Buckets[0] {
|
||||
from = lowest
|
||||
}
|
||||
|
||||
denseUpperBounds := append([]float64{}, aggBucket.Meta.Buckets[:offset]...)
|
||||
for index := from; index <= highest; index++ {
|
||||
denseUpperBounds = append(denseUpperBounds, bucketing.calculateUpperBoundAtIndex(index))
|
||||
}
|
||||
if len(denseUpperBounds) == len(aggBucket.Meta.Buckets) {
|
||||
return
|
||||
}
|
||||
|
||||
// Counts map through their index rather than by matching upper bounds, so a
|
||||
// regenerated upper bound differing from ClickHouse's in its last bit still
|
||||
// lands where it came from.
|
||||
shiftedTo := make([]int, len(aggBucket.Meta.Buckets))
|
||||
for i, index := range indexes {
|
||||
shiftedTo[i+offset] = index - from + offset
|
||||
}
|
||||
|
||||
overflowIndex := len(denseUpperBounds)
|
||||
for _, series := range aggBucket.Series {
|
||||
for _, point := range series.Values {
|
||||
if len(point.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
denseCounts := make([]float64, overflowIndex+1)
|
||||
for index, count := range point.Values {
|
||||
if index >= len(shiftedTo) {
|
||||
denseCounts[overflowIndex] += count
|
||||
continue
|
||||
}
|
||||
denseCounts[shiftedTo[index]] += count
|
||||
}
|
||||
point.Values = denseCounts
|
||||
}
|
||||
}
|
||||
aggBucket.Meta.Buckets = denseUpperBounds
|
||||
}
|
||||
|
||||
// calculateIndexOfUpperBound and calculateUpperBoundAtIndex are inverses over
|
||||
// the axis being returned, so they read h.LogScale rather than the MaxLogScale
|
||||
// ClickHouse bucketed at: k * maxValue / numBuckets on a linear axis,
|
||||
// 2^(k / 2^scale) on a log one.
|
||||
func (h HeatmapBucketing) calculateIndexOfUpperBound(upperBound float64) int {
|
||||
if h.Kind == BucketsKindLinear {
|
||||
return int(math.Round(upperBound * float64(h.NumBuckets) / h.MaxValue))
|
||||
}
|
||||
return int(math.Round(math.Log2(upperBound) * math.Exp2(float64(h.LogScale))))
|
||||
}
|
||||
|
||||
func (h HeatmapBucketing) calculateUpperBoundAtIndex(index int) float64 {
|
||||
if h.Kind == BucketsKindLinear {
|
||||
return float64(index) * h.MaxValue / float64(h.NumBuckets)
|
||||
}
|
||||
return math.Exp2(float64(index) / math.Exp2(float64(h.LogScale)))
|
||||
}
|
||||
@@ -734,3 +734,130 @@ func (r *QueryRangeRequest) GetQueriesSupportingZeroDefault() map[string]bool {
|
||||
|
||||
return canDefaultZero
|
||||
}
|
||||
|
||||
type BucketOptions struct {
|
||||
Kind BucketsKind `json:"kind"`
|
||||
Spec any `json:"spec"`
|
||||
}
|
||||
|
||||
type BucketsKind struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
BucketsKindLinear = BucketsKind{valuer.NewString("linear")}
|
||||
BucketsKindLog = BucketsKind{valuer.NewString("log")}
|
||||
)
|
||||
|
||||
// Enum implements jsonschema.Enum.
|
||||
func (BucketsKind) Enum() []any {
|
||||
return []any{
|
||||
BucketsKindLinear,
|
||||
BucketsKindLog,
|
||||
}
|
||||
}
|
||||
|
||||
// LinearBucketsSpec divides (0, MaxValue] into NumBuckets equal bands.
|
||||
type LinearBucketsSpec struct {
|
||||
// Everything above MaxValue is counted in the trailing overflow band. Evenly
|
||||
// spaced upper bounds have no top to divide without it, so it is required.
|
||||
MaxValue float64 `json:"maxValue" required:"true"`
|
||||
NumBuckets int `json:"numBuckets,omitempty"`
|
||||
}
|
||||
|
||||
// LogBucketsSpec spaces upper bounds at 2^Scale bands per doubling, the mapping
|
||||
// an exponential histogram uses.
|
||||
type LogBucketsSpec struct {
|
||||
// ClickHouse always buckets at MaxLogScale and the surplus is folded away
|
||||
// afterwards, so every Scale reads the same cache entry. MaxLogScale applies
|
||||
// when unset.
|
||||
Scale *int `json:"scale,omitempty"`
|
||||
}
|
||||
|
||||
func (b *BucketOptions) UnmarshalJSON(data []byte) error {
|
||||
var shadow struct {
|
||||
Kind BucketsKind `json:"kind"`
|
||||
Spec json.RawMessage `json:"spec"`
|
||||
}
|
||||
if err := binding.JSON.BindBody(bytes.NewReader(data), &shadow, binding.WithDisallowUnknownFields(true)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.Kind = shadow.Kind
|
||||
|
||||
// An absent spec is a malformed pair rather than a request for defaults;
|
||||
// `"spec": {}` asks for those.
|
||||
if len(shadow.Spec) == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"bucketOptions spec is required, use an empty object for the kind's defaults")
|
||||
}
|
||||
|
||||
switch shadow.Kind {
|
||||
case BucketsKindLinear:
|
||||
var spec LinearBucketsSpec
|
||||
if err := binding.JSON.BindBody(bytes.NewReader(shadow.Spec), &spec, binding.WithDisallowUnknownFields(true), binding.WithUnknownFieldContext("linear buckets spec")); err != nil {
|
||||
return err
|
||||
}
|
||||
b.Spec = spec
|
||||
|
||||
case BucketsKindLog:
|
||||
var spec LogBucketsSpec
|
||||
if err := binding.JSON.BindBody(bytes.NewReader(shadow.Spec), &spec, binding.WithDisallowUnknownFields(true), binding.WithUnknownFieldContext("log buckets spec")); err != nil {
|
||||
return err
|
||||
}
|
||||
b.Spec = spec
|
||||
|
||||
default:
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid bucketOptions kind: %s",
|
||||
shadow.Kind.StringValue(),
|
||||
).WithAdditional(
|
||||
"Valid bucket kinds are: linear, log",
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// bucketOptionsLinear and bucketOptionsLog are the OpenAPI schemas for the two
|
||||
// BucketOptions variants. They have to be named types: the reflector turns an
|
||||
// anonymous one into an inline subschema, leaving the discriminator mapping in
|
||||
// PrepareJSONSchema pointing at components that were never emitted. `kind` is
|
||||
// required:"true" on both so oapi-codegen renders the discriminator non-pointer.
|
||||
type bucketOptionsLinear struct {
|
||||
Kind BucketsKind `json:"kind" required:"true" description:"How the upper bounds are spaced."`
|
||||
Spec LinearBucketsSpec `json:"spec" required:"true" description:"The evenly spaced bucket specification."`
|
||||
}
|
||||
|
||||
type bucketOptionsLog struct {
|
||||
Kind BucketsKind `json:"kind" required:"true" description:"How the upper bounds are spaced."`
|
||||
Spec LogBucketsSpec `json:"spec" required:"true" description:"The logarithmic bucket specification."`
|
||||
}
|
||||
|
||||
var _ jsonschema.OneOfExposer = BucketOptions{}
|
||||
|
||||
func (BucketOptions) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
bucketOptionsLinear{},
|
||||
bucketOptionsLog{},
|
||||
}
|
||||
}
|
||||
|
||||
var _ jsonschema.Preparer = BucketOptions{}
|
||||
|
||||
// PrepareJSONSchema marks the options as a `kind`-discriminated union;
|
||||
// signoz.attachDiscriminators promotes it and strips the base properties.
|
||||
func (BucketOptions) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
if s.ExtraProperties == nil {
|
||||
s.ExtraProperties = map[string]any{}
|
||||
}
|
||||
s.ExtraProperties["x-signoz-discriminator"] = map[string]any{
|
||||
"propertyName": "kind",
|
||||
"mapping": map[string]string{
|
||||
BucketsKindLinear.StringValue(): "#/components/schemas/Querybuildertypesv5BucketOptionsLinear",
|
||||
BucketsKindLog.StringValue(): "#/components/schemas/Querybuildertypesv5BucketOptionsLog",
|
||||
},
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@ func (r *RequestType) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
v := RequestType{valuer.NewString(s)}
|
||||
switch v {
|
||||
case RequestTypeScalar, RequestTypeTimeSeries, RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeDistribution:
|
||||
case RequestTypeScalar, RequestTypeTimeSeries, RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeDistribution, RequestTypeHeatmap:
|
||||
*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`")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q; allowed values: %s", s, "`scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `distribution`, `heatmap`")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ var (
|
||||
RequestTypeTrace = RequestType{valuer.NewString("trace")}
|
||||
// []Bucket (struct{Lower,Upper,Count float64}), example: histogram.
|
||||
RequestTypeDistribution = RequestType{valuer.NewString("distribution")}
|
||||
// TimeSeriesData carrying one count per histogram bucket at each timestamp,
|
||||
// with the shared bucket upper bounds on the aggregation's meta.
|
||||
RequestTypeHeatmap = RequestType{valuer.NewString("heatmap")}
|
||||
)
|
||||
|
||||
// IsAggregation returns true for request types that produce aggregated results
|
||||
@@ -49,7 +52,7 @@ var (
|
||||
// For non-aggregation types (raw, raw_stream, trace), those fields are ignored
|
||||
// and don't need to be validated.
|
||||
func (r RequestType) IsAggregation() bool {
|
||||
return r == RequestTypeTimeSeries || r == RequestTypeScalar || r == RequestTypeDistribution
|
||||
return r == RequestTypeTimeSeries || r == RequestTypeScalar || r == RequestTypeDistribution || r == RequestTypeHeatmap
|
||||
}
|
||||
|
||||
// Enum implements jsonschema.Enum; returns the acceptable values for RequestType.
|
||||
@@ -60,6 +63,7 @@ func (RequestType) Enum() []any {
|
||||
RequestTypeRaw,
|
||||
RequestTypeRawStream,
|
||||
RequestTypeTrace,
|
||||
RequestTypeHeatmap,
|
||||
// RequestTypeDistribution,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,12 +138,10 @@ type TimeSeriesData struct {
|
||||
}
|
||||
|
||||
type AggregationBucket struct {
|
||||
Index int `json:"index"` // or string Alias
|
||||
Alias string `json:"alias"`
|
||||
Meta struct {
|
||||
Unit string `json:"unit,omitempty"`
|
||||
} `json:"meta,omitempty"`
|
||||
Series []*TimeSeries `json:"series"` // no extra nesting
|
||||
Index int `json:"index"` // or string Alias
|
||||
Alias string `json:"alias"`
|
||||
Meta AggregationMeta `json:"meta,omitempty"`
|
||||
Series []*TimeSeries `json:"series"` // no extra nesting
|
||||
|
||||
PredictedSeries []*TimeSeries `json:"predictedSeries,omitempty"`
|
||||
UpperBoundSeries []*TimeSeries `json:"upperBoundSeries,omitempty"`
|
||||
@@ -151,6 +149,54 @@ type AggregationBucket struct {
|
||||
AnomalyScores []*TimeSeries `json:"anomalyScores,omitempty"`
|
||||
}
|
||||
|
||||
// ReindexValuesToNewUpperBounds moves each count to the index its upper bound
|
||||
// holds in onto, a superset of Meta.Buckets. No count changes, only its position
|
||||
// in Values.
|
||||
func (a *AggregationBucket) ReindexValuesToNewUpperBounds(onto []float64) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
|
||||
from := a.Meta.Buckets
|
||||
if len(onto) == 0 || slices.Equal(from, onto) {
|
||||
return
|
||||
}
|
||||
|
||||
upperBoundToIndex := make(map[float64]int, len(onto))
|
||||
for index, upperBound := range onto {
|
||||
upperBoundToIndex[upperBound] = index
|
||||
}
|
||||
|
||||
for _, series := range a.Series {
|
||||
for _, point := range series.Values {
|
||||
if len(point.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
reindexed := make([]float64, len(onto)+1)
|
||||
for index, count := range point.Values {
|
||||
if index >= len(from) {
|
||||
reindexed[len(onto)] = count
|
||||
break
|
||||
}
|
||||
if newIndex, ok := upperBoundToIndex[from[index]]; ok {
|
||||
reindexed[newIndex] = count
|
||||
}
|
||||
}
|
||||
point.Values = reindexed
|
||||
}
|
||||
}
|
||||
|
||||
a.Meta.Buckets = onto
|
||||
}
|
||||
|
||||
type AggregationMeta struct {
|
||||
Unit string `json:"unit,omitempty"`
|
||||
// Buckets holds ascending upper bounds shared by every series in the
|
||||
// AggregationBucket, set only for heatmap results. Each point's Values holds
|
||||
// len(Buckets)+1 counts: one per bound, then the open-above overflow.
|
||||
Buckets []float64 `json:"buckets,omitempty"`
|
||||
}
|
||||
|
||||
type TimeSeries struct {
|
||||
Labels []*Label `json:"labels,omitempty"`
|
||||
Values []*TimeSeriesValue `json:"values"`
|
||||
@@ -254,13 +300,9 @@ type TimeSeriesValue struct {
|
||||
// on the client side, these partial values are rendered differently.
|
||||
Partial bool `json:"partial,omitempty"`
|
||||
|
||||
// for the heatmap type chart
|
||||
// Values holds one count per histogram bucket for heatmap results, in the
|
||||
// order of the aggregation's Meta.Buckets. Value is omitted in that case.
|
||||
Values []float64 `json:"values,omitempty"`
|
||||
Bucket *Bucket `json:"bucket,omitempty"`
|
||||
}
|
||||
|
||||
type Bucket struct {
|
||||
Step float64 `json:"step"`
|
||||
}
|
||||
|
||||
type ColumnType struct {
|
||||
@@ -481,13 +523,20 @@ func (t TimeSeriesValue) MarshalJSON() ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// a heatmap point's counts are spread across Values, so there is no one
|
||||
// number Value could carry
|
||||
var sanitizedValue any
|
||||
if t.Values == nil {
|
||||
sanitizedValue = sanitizeValue(t.Value)
|
||||
}
|
||||
|
||||
return json.Marshal(&struct {
|
||||
*Alias
|
||||
Value any `json:"value"`
|
||||
Value any `json:"value,omitempty"`
|
||||
Values any `json:"values,omitempty"`
|
||||
}{
|
||||
Alias: (*Alias)(&t),
|
||||
Value: sanitizeValue(t.Value),
|
||||
Value: sanitizedValue,
|
||||
Values: sanitizedValues,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -50,13 +50,21 @@ func TestTimeSeriesValue_MarshalJSON(t *testing.T) {
|
||||
expected: `{"timestamp":1234567890,"value":"-Inf"}`,
|
||||
},
|
||||
{
|
||||
name: "values array with NaN",
|
||||
name: "zero value",
|
||||
value: TimeSeriesValue{
|
||||
Timestamp: 1234567890,
|
||||
Value: 0,
|
||||
},
|
||||
expected: `{"timestamp":1234567890,"value":0}`,
|
||||
},
|
||||
{
|
||||
name: "values array with NaN, and no value beside it",
|
||||
value: TimeSeriesValue{
|
||||
Timestamp: 1234567890,
|
||||
Value: 1.0,
|
||||
Values: []float64{1.0, math.NaN(), 3.0, math.Inf(1)},
|
||||
},
|
||||
expected: `{"timestamp":1234567890,"value":1,"values":[1,"NaN",3,"Inf"]}`,
|
||||
expected: `{"timestamp":1234567890,"values":[1,"NaN",3,"Inf"]}`,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ func calculateSeriesValue(series *TimeSeries) float64 {
|
||||
|
||||
// For single-point series, return that value directly
|
||||
if len(series.Values) == 1 {
|
||||
value := series.Values[0].Value
|
||||
value := calculatePointValue(series.Values[0])
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return 0.0
|
||||
}
|
||||
@@ -139,10 +139,11 @@ func calculateSeriesValue(series *TimeSeries) float64 {
|
||||
var count float64
|
||||
|
||||
for _, point := range series.Values {
|
||||
if math.IsNaN(point.Value) || math.IsInf(point.Value, 0) {
|
||||
value := calculatePointValue(point)
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
continue
|
||||
}
|
||||
sum += point.Value
|
||||
sum += value
|
||||
count++
|
||||
}
|
||||
|
||||
@@ -154,6 +155,25 @@ func calculateSeriesValue(series *TimeSeries) float64 {
|
||||
return sum / count
|
||||
}
|
||||
|
||||
// calculatePointValue returns what a point contributes to its series' rank.
|
||||
// Heatmap points carry one count per bucket in Values and leave Value at zero,
|
||||
// so they rank on the total across buckets.
|
||||
func calculatePointValue(point *TimeSeriesValue) float64 {
|
||||
if len(point.Values) == 0 {
|
||||
return point.Value
|
||||
}
|
||||
|
||||
var total float64
|
||||
for _, value := range point.Values {
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
continue
|
||||
}
|
||||
total += value
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// convertValueToString converts various types to string for comparison.
|
||||
func convertValueToString(value any) string {
|
||||
switch v := value.(type) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplySeriesLimit(t *testing.T) {
|
||||
@@ -232,3 +234,81 @@ func TestApplySeriesLimit(t *testing.T) {
|
||||
assert.Equal(t, 40.0, result[2].Values[0].Value)
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplySeriesLimitRanksHeatmapSeriesByBucketTotals(t *testing.T) {
|
||||
// A reshaped heatmap point leaves Value at zero and holds one count per
|
||||
// bucket in Values, so ranking has to sum the buckets to see any difference.
|
||||
series := []*TimeSeries{
|
||||
{
|
||||
Labels: []*Label{{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: "service.name"},
|
||||
Value: "quiet",
|
||||
}},
|
||||
Values: []*TimeSeriesValue{
|
||||
{Timestamp: 1000, Values: []float64{1, 2, 0}},
|
||||
{Timestamp: 1060, Values: []float64{0, 1, 0}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: []*Label{{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: "service.name"},
|
||||
Value: "busy",
|
||||
}},
|
||||
Values: []*TimeSeriesValue{
|
||||
{Timestamp: 1000, Values: []float64{40, 60, 5}},
|
||||
{Timestamp: 1060, Values: []float64{30, 70, 5}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: []*Label{{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: "service.name"},
|
||||
Value: "middling",
|
||||
}},
|
||||
Values: []*TimeSeriesValue{
|
||||
{Timestamp: 1000, Values: []float64{5, 5, 0}},
|
||||
{Timestamp: 1060, Values: []float64{4, 6, 0}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ApplySeriesLimit(series, nil, 2)
|
||||
|
||||
require.Len(t, result, 2)
|
||||
assert.Equal(t, "busy", result[0].Labels[0].Value)
|
||||
assert.Equal(t, "middling", result[1].Labels[0].Value)
|
||||
}
|
||||
|
||||
func TestCalculatePointValue(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
point *TimeSeriesValue
|
||||
expectedValue float64
|
||||
}{
|
||||
{
|
||||
description: "a plain time series point ranks on its single value",
|
||||
point: &TimeSeriesValue{Timestamp: 1000, Value: 7},
|
||||
expectedValue: 7,
|
||||
},
|
||||
{
|
||||
description: "a heatmap point ranks on the total across its buckets",
|
||||
point: &TimeSeriesValue{Timestamp: 1000, Values: []float64{1, 12, 14, 3}},
|
||||
expectedValue: 30,
|
||||
},
|
||||
{
|
||||
description: "non-finite bucket counts are skipped",
|
||||
point: &TimeSeriesValue{Timestamp: 1000, Values: []float64{2, math.NaN(), math.Inf(1), 3}},
|
||||
expectedValue: 5,
|
||||
},
|
||||
{
|
||||
description: "an empty bucket list falls back to the single value",
|
||||
point: &TimeSeriesValue{Timestamp: 1000, Value: 4, Values: []float64{}},
|
||||
expectedValue: 4,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.expectedValue, calculatePointValue(testCase.point))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -65,6 +66,8 @@ func wrapValidationError(cause error, contextIdentifier string, errorFormat stri
|
||||
const (
|
||||
// Maximum limit for query results.
|
||||
MaxQueryLimit = 10000
|
||||
|
||||
MaxNumBuckets = 512
|
||||
)
|
||||
|
||||
// ValidationOption is a functional option for configuring validation behaviour.
|
||||
@@ -581,7 +584,7 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
|
||||
// Validate request type
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar:
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
default:
|
||||
return errors.NewInvalidInputf(
|
||||
@@ -589,10 +592,14 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
"invalid request type: %s",
|
||||
r.RequestType,
|
||||
).WithAdditional(
|
||||
"Valid request types are: raw, timeseries, scalar",
|
||||
"Valid request types are: raw, timeseries, scalar, heatmap",
|
||||
)
|
||||
}
|
||||
|
||||
if err := r.validateHeatmap(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// raw/trace request types don't support metric queries;
|
||||
// metrics are always aggregated and there is no raw form.
|
||||
if r.RequestType == RequestTypeRaw || r.RequestType == RequestTypeRawStream || r.RequestType == RequestTypeTrace {
|
||||
@@ -630,11 +637,15 @@ func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
|
||||
|
||||
var opts []ValidationOption
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar:
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
|
||||
opts = GetValidationOptions(r.RequestType)
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
|
||||
WithAdditional("Valid request types are: raw, timeseries, scalar")
|
||||
WithAdditional("Valid request types are: raw, timeseries, scalar, heatmap")
|
||||
}
|
||||
|
||||
if err := r.validateHeatmap(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if r.RequestType == RequestTypeRaw || r.RequestType == RequestTypeRawStream || r.RequestType == RequestTypeTrace {
|
||||
@@ -838,9 +849,172 @@ func validateQueryEnvelope(envelope QueryEnvelope, opts ...ValidationOption) err
|
||||
}
|
||||
}
|
||||
|
||||
func (r *QueryRangeRequest) validateHeatmap() error {
|
||||
if r.RequestType != RequestTypeHeatmap {
|
||||
for _, envelope := range r.CompositeQuery.Queries {
|
||||
if extractEnabledBucketOptions(envelope) != nil {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"bucketOptions are only supported for heatmap requests, got %s",
|
||||
r.RequestType,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if r.FormatOptions != nil && r.FormatOptions.FillGaps {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"fillGaps is not supported for heatmap requests: an absent column means collection stopped, which a zero-filled column would hide")
|
||||
}
|
||||
|
||||
enabled := 0
|
||||
for _, envelope := range r.CompositeQuery.Queries {
|
||||
if err := extractEnabledBucketOptions(envelope).validateBucketOptions(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch spec := envelope.Spec.(type) {
|
||||
case QueryBuilderQuery[MetricAggregation]:
|
||||
if err := validateHeatmapQuery(spec.Functions, spec.Having); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.Disabled {
|
||||
continue
|
||||
}
|
||||
enabled++
|
||||
case QueryBuilderFormula:
|
||||
if err := validateHeatmapQuery(spec.Functions, spec.Having); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.Disabled {
|
||||
continue
|
||||
}
|
||||
enabled++
|
||||
case ClickHouseQuery:
|
||||
if spec.Disabled {
|
||||
continue
|
||||
}
|
||||
enabled++
|
||||
case PromQuery:
|
||||
if spec.Disabled {
|
||||
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")
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"heatmap requests support one metrics builder query, one formula over them, one clickhouse query, or one promql query, got %q", envelope.Type.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
// A disabled query is a formula input rather than something to draw, so a
|
||||
// request can carry queries and still have none to render.
|
||||
switch {
|
||||
case len(r.CompositeQuery.Queries) == 0:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"at least one query is required")
|
||||
case enabled == 0:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"a heatmap needs one enabled query, but every query is disabled").
|
||||
WithAdditional("Enable the query whose distribution you want to plot")
|
||||
case enabled > 1:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"a heatmap renders one distribution, but %d queries are enabled", enabled).
|
||||
WithAdditional(
|
||||
"Disable the queries you don't want to plot, keeping only the one whose distribution you want to show",
|
||||
"A formula can stay enabled with the queries it reads disabled",
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractEnabledBucketOptions returns the bucket axis a query asks for, nil for
|
||||
// a disabled one: a heatmap draws the enabled query alone, so what the rest
|
||||
// carry is never read. A promql or clickhouse query has no say in its axis and
|
||||
// so has nowhere to state one.
|
||||
func extractEnabledBucketOptions(envelope QueryEnvelope) *BucketOptions {
|
||||
var disabled bool
|
||||
var bucketOptions *BucketOptions
|
||||
|
||||
switch spec := envelope.Spec.(type) {
|
||||
case QueryBuilderQuery[MetricAggregation]:
|
||||
disabled, bucketOptions = spec.Disabled, spec.BucketOptions
|
||||
case QueryBuilderQuery[LogAggregation]:
|
||||
disabled, bucketOptions = spec.Disabled, spec.BucketOptions
|
||||
case QueryBuilderQuery[TraceAggregation]:
|
||||
disabled, bucketOptions = spec.Disabled, spec.BucketOptions
|
||||
case QueryBuilderFormula:
|
||||
disabled, bucketOptions = spec.Disabled, spec.BucketOptions
|
||||
}
|
||||
|
||||
if disabled {
|
||||
return nil
|
||||
}
|
||||
return bucketOptions
|
||||
}
|
||||
|
||||
func (b *BucketOptions) validateBucketOptions() error {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch spec := b.Spec.(type) {
|
||||
case LinearBucketsSpec:
|
||||
if math.IsNaN(spec.MaxValue) || math.IsInf(spec.MaxValue, 0) || spec.MaxValue <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"linear buckets need a finite maxValue greater than 0, got %v", spec.MaxValue)
|
||||
}
|
||||
if spec.NumBuckets < 0 || spec.NumBuckets > MaxNumBuckets {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"numBuckets must be between 1 and %d, got %d", MaxNumBuckets, spec.NumBuckets)
|
||||
}
|
||||
|
||||
case LogBucketsSpec:
|
||||
if spec.Scale != nil && (*spec.Scale < MinLogScale || *spec.Scale > MaxLogScale) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"scale must be between %d and %d, got %d", MinLogScale, MaxLogScale, *spec.Scale)
|
||||
}
|
||||
|
||||
default:
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid bucketOptions kind: %s",
|
||||
b.Kind.StringValue(),
|
||||
).WithAdditional(
|
||||
"Valid bucket kinds are: linear, log",
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateHeatmapQuery refuses the per-query settings that cannot mean anything
|
||||
// on a heatmap. It runs on disabled queries too: a disabled query is a formula
|
||||
// input, so whatever it does still reaches the cells.
|
||||
func validateHeatmapQuery(functions []Function, having *Having) error {
|
||||
if len(functions) > 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"functions are not supported for heatmap requests: a heatmap point is a count per bucket, not a single value")
|
||||
}
|
||||
|
||||
if having != nil && having.Expression != "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"having is not supported for heatmap requests: it filters individual cells, which breaks the cumulative differencing")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetValidationOptions(requestType RequestType) []ValidationOption {
|
||||
switch requestType {
|
||||
case RequestTypeTimeSeries:
|
||||
case RequestTypeTimeSeries, RequestTypeHeatmap:
|
||||
return []ValidationOption{WithSkipSelectFieldValidation(), WithTimestampGroupByValidation()}
|
||||
case RequestTypeScalar:
|
||||
return []ValidationOption{WithSkipSelectFieldValidation(), WithReduceToValidation()}
|
||||
|
||||
39
tests/fixtures/querier.py
vendored
39
tests/fixtures/querier.py
vendored
@@ -33,6 +33,7 @@ class RequestType:
|
||||
TIME_SERIES = "time_series"
|
||||
SCALAR = "scalar"
|
||||
TABLE = "table"
|
||||
HEATMAP = "heatmap"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -298,6 +299,7 @@ def build_builder_query(
|
||||
group_by: list[str] | None = None,
|
||||
filter_expression: str | None = None,
|
||||
functions: list[dict] | None = None,
|
||||
bucket_options: dict | None = None,
|
||||
disabled: bool = False,
|
||||
) -> dict:
|
||||
spec: dict[str, Any] = {
|
||||
@@ -319,6 +321,8 @@ def build_builder_query(
|
||||
spec["aggregations"][0]["temporality"] = temporality
|
||||
if comparisonSpaceAggregationParam:
|
||||
spec["aggregations"][0]["comparisonSpaceAggregationParam"] = comparisonSpaceAggregationParam
|
||||
if bucket_options is not None:
|
||||
spec["bucketOptions"] = bucket_options
|
||||
if group_by:
|
||||
spec["groupBy"] = [
|
||||
{
|
||||
@@ -341,6 +345,7 @@ def build_formula_query(
|
||||
expression: str,
|
||||
*,
|
||||
functions: list[dict] | None = None,
|
||||
bucket_options: dict | None = None,
|
||||
disabled: bool = False,
|
||||
order: list[dict] | None = None,
|
||||
limit: int | None = None,
|
||||
@@ -352,6 +357,8 @@ def build_formula_query(
|
||||
}
|
||||
if functions:
|
||||
spec["functions"] = functions
|
||||
if bucket_options is not None:
|
||||
spec["bucketOptions"] = bucket_options
|
||||
if order:
|
||||
spec["order"] = order
|
||||
if limit is not None:
|
||||
@@ -359,6 +366,20 @@ def build_formula_query(
|
||||
return {"type": "builder_formula", "spec": spec}
|
||||
|
||||
|
||||
def build_log_bucket_options(scale: int | None = None) -> dict:
|
||||
spec: dict[str, Any] = {}
|
||||
if scale is not None:
|
||||
spec["scale"] = scale
|
||||
return {"kind": "log", "spec": spec}
|
||||
|
||||
|
||||
def build_linear_bucket_options(max_value: float, num_buckets: int | None = None) -> dict:
|
||||
spec: dict[str, Any] = {"maxValue": max_value}
|
||||
if num_buckets is not None:
|
||||
spec["numBuckets"] = num_buckets
|
||||
return {"kind": "linear", "spec": spec}
|
||||
|
||||
|
||||
def build_function(name: str, *args: Any) -> dict:
|
||||
func: dict[str, Any] = {"name": name}
|
||||
if args:
|
||||
@@ -393,6 +414,24 @@ def get_all_series(response_json: dict, query_name: str) -> list[dict]:
|
||||
return aggregations[0].get("series", [])
|
||||
|
||||
|
||||
def get_heatmap_buckets(response_json: dict, query_name: str) -> list[float]:
|
||||
"""The ascending bucket upper bounds a heatmap result's counts are positional against.
|
||||
Each point holds one more count than there are bounds: the trailing one is the open-above overflow."""
|
||||
results = response_json.get("data", {}).get("data", {}).get("results", [])
|
||||
result = find_named_result(results, query_name)
|
||||
if not result:
|
||||
return []
|
||||
aggregations = result.get("aggregations", [])
|
||||
if not aggregations:
|
||||
return []
|
||||
return aggregations[0].get("meta", {}).get("buckets", [])
|
||||
|
||||
|
||||
def get_heatmap_columns(response_json: dict, query_name: str) -> list[dict]:
|
||||
"""A heatmap result's points for its single series, oldest first."""
|
||||
return sorted(get_series_values(response_json, query_name), key=lambda point: point["timestamp"])
|
||||
|
||||
|
||||
def get_scalar_value(response_json: dict, query_name: str) -> float | None:
|
||||
values = get_series_values(response_json, query_name)
|
||||
if values:
|
||||
|
||||
24
tests/integration/testdata/heatmap_histogram_3m.jsonl
vendored
Normal file
24
tests/integration/testdata/heatmap_histogram_3m.jsonl
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "1"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 10, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "2"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 20, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "4"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 30, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "+Inf"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 40, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "1"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 50, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "2"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 60, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "4"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 70, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "+Inf"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 80, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "1"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 11, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "2"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 23, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "4"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 33, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "+Inf"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 44, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "1"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 50, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "2"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 61, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "4"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 72, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "+Inf"}, "timestamp": "2025-01-10T10:01:00+00:00", "value": 82, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "1"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 13, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "2"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 25, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "4"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 39, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "api", "le": "+Inf"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 50, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "1"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 51, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "2"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 62, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "4"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 73, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
{"metric_name": "heatmap_request_duration_bucket", "labels": {"__temporality__": "Cumulative", "service": "web", "le": "+Inf"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 85, "temporality": "Cumulative", "type_": "Histogram", "is_monotonic": true, "flags": 0, "description": "", "unit": "", "env": "default", "resource_attrs": {}, "scope_attrs": {}}
|
||||
780
tests/integration/tests/queriermetrics/15_heatmap.py
Normal file
780
tests/integration/tests/queriermetrics/15_heatmap.py
Normal file
@@ -0,0 +1,780 @@
|
||||
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.fs import get_testdata_file_path
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.querier import (
|
||||
RequestType,
|
||||
assert_identical_query_response,
|
||||
build_builder_query,
|
||||
build_formula_query,
|
||||
build_linear_bucket_options,
|
||||
build_log_bucket_options,
|
||||
get_all_series,
|
||||
get_all_warnings,
|
||||
get_heatmap_buckets,
|
||||
get_heatmap_columns,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
HISTOGRAM_FILE = get_testdata_file_path("histogram_data_1h.jsonl")
|
||||
HISTOGRAM_COUNTERS_FILE = get_testdata_file_path("heatmap_histogram_3m.jsonl")
|
||||
MINUTE_MS = 60_000
|
||||
|
||||
|
||||
def test_gauge_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
minutes = 3
|
||||
start_ms = int((now - timedelta(minutes=minutes + 1)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_gauge"
|
||||
|
||||
value_by_host = {f"host-{host:02d}": (200, 400, 800)[host // 8] + host for host in range(24)}
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"host": host},
|
||||
timestamp=now - timedelta(minutes=minutes - minute),
|
||||
value=value,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for host, value in value_by_host.items()
|
||||
for minute in range(minutes)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "max", "max", group_by=["host"], bucket_options=build_log_bucket_options(0))],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
# a group by gives one series per host, and all of them are counted against
|
||||
# this one axis. 128 is on it so the lowest bucket holding anything reads as
|
||||
# (128, 256] rather than as everything at or below 256
|
||||
assert get_heatmap_buckets(data, "A") == pytest.approx([128.0, 256.0, 512.0, 1024.0])
|
||||
|
||||
columns_by_host = {host: sorted(series["values"], key=lambda column: column["timestamp"]) for host, series in index_series_by_label(get_all_series(data, "A"), "host").items()}
|
||||
assert len(columns_by_host) == len(value_by_host)
|
||||
|
||||
# a column carries its per-bucket counts and no `value`, since no single
|
||||
# number stands for a spread
|
||||
assert all("value" not in column for columns in columns_by_host.values() for column in columns)
|
||||
|
||||
# a column holds one count per bucket plus a trailing one for the overflow.
|
||||
# the 200s reach (128, 256], the 400s (256, 512] and the 800s (512, 1024],
|
||||
# and every minute records the same value
|
||||
expected_columns = [
|
||||
[0, 1, 0, 0, 0],
|
||||
[0, 0, 1, 0, 0],
|
||||
[0, 0, 0, 1, 0],
|
||||
]
|
||||
for host, columns in columns_by_host.items():
|
||||
assert [column["values"] for column in columns] == [expected_columns[int(host.removeprefix("host-")) // 8]] * minutes
|
||||
|
||||
# summed across the hosts, a column is the spread of the 24 of them
|
||||
for minute in range(minutes):
|
||||
assert [sum(columns[minute]["values"][slot] for columns in columns_by_host.values()) for slot in range(5)] == [0, 8, 8, 8, 0]
|
||||
|
||||
|
||||
def test_sum_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
minutes = 3
|
||||
start_ms = int((now - timedelta(minutes=minutes + 1)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_sum"
|
||||
|
||||
value_by_endpoint = {f"/endpoint-{endpoint:02d}": (100, 800)[endpoint // 8] + endpoint for endpoint in range(16)}
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"endpoint": endpoint},
|
||||
timestamp=now - timedelta(minutes=minutes - minute),
|
||||
value=value,
|
||||
temporality="Cumulative",
|
||||
type_="Sum",
|
||||
)
|
||||
for endpoint, value in value_by_endpoint.items()
|
||||
for minute in range(minutes)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "max", "max", temporality="cumulative", group_by=["endpoint"], bucket_options=build_log_bucket_options(0))],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
assert get_heatmap_buckets(data, "A") == pytest.approx([64.0, 128.0, 256.0, 512.0, 1024.0])
|
||||
|
||||
columns_by_endpoint = {endpoint: sorted(series["values"], key=lambda column: column["timestamp"]) for endpoint, series in index_series_by_label(get_all_series(data, "A"), "endpoint").items()}
|
||||
assert len(columns_by_endpoint) == len(value_by_endpoint)
|
||||
|
||||
# the 100s reach (64, 128] and the 800s (512, 1024], and every minute
|
||||
# records the same value
|
||||
expected_columns = [
|
||||
[0, 1, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 1, 0],
|
||||
]
|
||||
for endpoint, columns in columns_by_endpoint.items():
|
||||
assert [column["values"] for column in columns] == [expected_columns[int(endpoint.removeprefix("/endpoint-")) // 8]] * minutes
|
||||
|
||||
for minute in range(minutes):
|
||||
assert [sum(columns[minute]["values"][slot] for columns in columns_by_endpoint.values()) for slot in range(6)] == [0, 8, 0, 0, 8, 0]
|
||||
|
||||
|
||||
def test_histogram_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_histogram"
|
||||
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
HISTOGRAM_FILE,
|
||||
base_time=now - timedelta(minutes=60),
|
||||
metric_name_override=metric_name,
|
||||
)
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "increase", "p50", group_by=["le"], filter_expression='endpoint = "/health"')],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
# a histogram's axis is its recorded `le` bounds exactly, since nothing is
|
||||
# known about what sits between two of them; `le=+Inf` has no finite bound
|
||||
# and is counted in the trailing overflow slot
|
||||
assert get_heatmap_buckets(data, "A") == [1000, 1500, 2000, 4000, 5000, 6000, 8000]
|
||||
|
||||
columns = get_heatmap_columns(data, "A")
|
||||
assert columns
|
||||
for column in columns:
|
||||
assert len(column["values"]) == 8
|
||||
assert all(count >= 0 for count in column["values"])
|
||||
assert any(sum(column["values"]) > 0 for column in columns)
|
||||
|
||||
|
||||
def test_linear_buckets(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=30)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_linear"
|
||||
|
||||
# 100 wide buckets: 100 lands on the first, 250 on the third, and 1500 is
|
||||
# past maxValue so it counts in the overflow
|
||||
values = [100, 250, 1500]
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "api"},
|
||||
timestamp=now - timedelta(minutes=len(values) - minute),
|
||||
value=value,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute, value in enumerate(values)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "max", "max", bucket_options=build_linear_bucket_options(1000, 10))],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
# 200 is on the axis although nothing reached it, so the gap between 100 and
|
||||
# 300 renders as a gap, and 0 is on it so the lowest bucket reads as (0, 100]
|
||||
assert get_heatmap_buckets(data, "A") == pytest.approx([0.0, 100.0, 200.0, 300.0])
|
||||
|
||||
columns = get_heatmap_columns(data, "A")
|
||||
assert [column["values"] for column in columns] == [
|
||||
[0, 1, 0, 0, 0],
|
||||
[0, 0, 0, 1, 0],
|
||||
[0, 0, 0, 0, 1],
|
||||
]
|
||||
|
||||
|
||||
def test_zero_bucket(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=30)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_zero_bucket"
|
||||
|
||||
values = [0, 256, 1024]
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "api"},
|
||||
timestamp=now - timedelta(minutes=len(values) - minute),
|
||||
value=value,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute, value in enumerate(values)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "max", "max", bucket_options=build_log_bucket_options(0))],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
# a log axis cannot place a value at or below zero, so those share a bucket
|
||||
# of their own beneath the rest. 128 separates that bucket from the lowest
|
||||
# one holding anything, and 512 is spanned above it
|
||||
assert get_heatmap_buckets(data, "A") == pytest.approx([0.0, 128.0, 256.0, 512.0, 1024.0])
|
||||
|
||||
columns = get_heatmap_columns(data, "A")
|
||||
assert [column["values"] for column in columns] == [
|
||||
[1, 0, 0, 0, 0, 0],
|
||||
[0, 0, 1, 0, 0, 0],
|
||||
[0, 0, 0, 0, 1, 0],
|
||||
]
|
||||
|
||||
|
||||
def test_single_bucket(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=30)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_single_bucket"
|
||||
|
||||
values = [256, 256, 256]
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "api"},
|
||||
timestamp=now - timedelta(minutes=len(values) - minute),
|
||||
value=value,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute, value in enumerate(values)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "max", "max")],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
# a single bucket leaves no gap to span, and still gets the rung below it so
|
||||
# its own lower bound is stated
|
||||
data = response.json()
|
||||
assert get_heatmap_buckets(data, "A") == pytest.approx([256 * 2 ** (-1 / 16), 256.0])
|
||||
assert [column["values"] for column in get_heatmap_columns(data, "A")] == [
|
||||
[0, 1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 1, 0],
|
||||
]
|
||||
|
||||
|
||||
# the values seeded below are 256 and 512, so each axis here runs from the rung
|
||||
# under the bucket holding 256 to the one holding 512 at that option's
|
||||
# resolution: 16 log buckets to the 2x, one bucket per 16x, or a linear bucket
|
||||
# every maxValue/numBuckets
|
||||
@pytest.mark.parametrize(
|
||||
"bucket_options, expected_buckets",
|
||||
[
|
||||
(None, [256 * 2 ** (step / 16) for step in range(-1, 17)]),
|
||||
({"kind": "log", "spec": {}}, [256 * 2 ** (step / 16) for step in range(-1, 17)]),
|
||||
(build_log_bucket_options(4), [256 * 2 ** (step / 16) for step in range(-1, 17)]),
|
||||
(build_log_bucket_options(-4), [1, 2**16]),
|
||||
(build_linear_bucket_options(1024), [step * 1024 / 60 for step in range(14, 31)]),
|
||||
(build_linear_bucket_options(1024, 512), [step * 1024 / 512 for step in range(127, 257)]),
|
||||
],
|
||||
ids=["absent", "log_defaults", "the_finest_scale", "the_coarsest_scale", "linear_without_num_buckets", "the_most_buckets"],
|
||||
)
|
||||
def test_bucket_option_limits(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
bucket_options: dict | None,
|
||||
expected_buckets: list[float],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=30)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_bucket_option_limits"
|
||||
|
||||
values = [256, 512]
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "api"},
|
||||
timestamp=now - timedelta(minutes=len(values) - minute),
|
||||
value=value,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute, value in enumerate(values)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "max", "max", bucket_options=bucket_options)],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
assert get_heatmap_buckets(data, "A") == pytest.approx(expected_buckets)
|
||||
|
||||
columns = get_heatmap_columns(data, "A")
|
||||
assert len(columns) == len(values)
|
||||
for column in columns:
|
||||
assert len(column["values"]) == len(expected_buckets) + 1
|
||||
assert sum(column["values"]) == 1
|
||||
|
||||
|
||||
def test_bucket_options_come_from_the_enabled_query(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=30)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_bucket_options_on_two_queries"
|
||||
|
||||
values = [256, 512]
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "api"},
|
||||
timestamp=now - timedelta(minutes=len(values) - minute),
|
||||
value=value,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute, value in enumerate(values)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[
|
||||
build_builder_query("A", metric_name, "max", "max", disabled=True, bucket_options=build_linear_bucket_options(1024, 512)),
|
||||
build_builder_query("B", metric_name, "max", "max", bucket_options=build_log_bucket_options(-4)),
|
||||
],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
# B's axis, one bucket per 16x. A's 512 linear buckets to 1024 would have
|
||||
# put 130 of them between 256 and 512 alone
|
||||
assert get_heatmap_buckets(data, "B") == pytest.approx([1, 2**16])
|
||||
assert [column["values"] for column in get_heatmap_columns(data, "B")] == [
|
||||
[0, 1, 0],
|
||||
[0, 1, 0],
|
||||
]
|
||||
|
||||
|
||||
def test_formula_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=30)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_formula"
|
||||
|
||||
values = [100, 260, 295, 512, 1500]
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "api"},
|
||||
timestamp=now - timedelta(minutes=len(values) - minute),
|
||||
value=value,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute, value in enumerate(values)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
from_metric = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "max", "max", bucket_options=build_log_bucket_options(2))],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert from_metric.status_code == HTTPStatus.OK, from_metric.text
|
||||
|
||||
# a formula over the same query has to land its counts on the same axis. the
|
||||
# scale on the disabled input is out of range: only the query the heatmap
|
||||
# draws states an axis, so nothing reads that one
|
||||
from_formula = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[
|
||||
build_builder_query("A", metric_name, "max", "max", disabled=True, bucket_options={"kind": "log", "spec": {"scale": 5}}),
|
||||
build_formula_query("F1", "A", bucket_options=build_log_bucket_options(2)),
|
||||
],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert from_formula.status_code == HTTPStatus.OK, from_formula.text
|
||||
|
||||
assert get_heatmap_buckets(from_formula.json(), "F1") == pytest.approx(get_heatmap_buckets(from_metric.json(), "A"))
|
||||
assert [column["values"] for column in get_heatmap_columns(from_formula.json(), "F1")] == [column["values"] for column in get_heatmap_columns(from_metric.json(), "A")]
|
||||
|
||||
|
||||
def test_promql_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
end_ms = (int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000) // MINUTE_MS) * MINUTE_MS
|
||||
start_ms = end_ms - MINUTE_MS
|
||||
|
||||
# the file's three columns are one minute apart, and the first sits a minute
|
||||
# before the query window so the earliest step has something to increase over.
|
||||
# Every counter in it stays above its own rise across a window, below which
|
||||
# increase clips its back-extrapolation at the counter's zero point.
|
||||
insert_metrics(Metrics.load_from_file(HISTOGRAM_COUNTERS_FILE, base_time=datetime.fromtimestamp((start_ms - MINUTE_MS) / 1000, tz=UTC)))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[{"type": "promql", "spec": {"name": "A", "query": "sum by (le) (increase(heatmap_request_duration_bucket[2m]))", "step": 60}}],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
assert get_heatmap_buckets(data, "A") == [1, 2, 4]
|
||||
|
||||
series = get_all_series(data, "A")
|
||||
assert len(series) == 1
|
||||
# `le` is what the bucket axis is read off, so it is never a group label
|
||||
assert series[0].get("labels") in (None, [])
|
||||
|
||||
# what the query returns per `le` is still cumulative across `le`, so each
|
||||
# count here is its own minus the one below it, and `le=+Inf` has no finite
|
||||
# bound to sit on and lands in the trailing slot. increase over a 2m window of
|
||||
# minutely samples extrapolates one minute's rise to two, hence the scaling.
|
||||
assert [column["values"] for column in get_heatmap_columns(data, "A")] == [
|
||||
[2, 6, 2, 2],
|
||||
[6, 0, 8, 4],
|
||||
]
|
||||
|
||||
|
||||
def test_promql_heatmap_with_no_data(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
end_ms = (int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000) // MINUTE_MS) * MINUTE_MS
|
||||
start_ms = end_ms - MINUTE_MS
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[{"type": "promql", "spec": {"name": "A", "query": "sum by (le) (increase(promql_heatmap_bucket_never_written[2m]))", "step": 60}}],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
# a window holding nothing is not a query that can never draw a heatmap, so
|
||||
# it comes back empty where the latter is rejected
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
assert get_heatmap_buckets(data, "A") == []
|
||||
assert get_heatmap_columns(data, "A") == []
|
||||
|
||||
|
||||
def test_clickhouse_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start = now - timedelta(minutes=2)
|
||||
metric_name = "test_heatmap_clickhouse"
|
||||
|
||||
# cut into tens these fill (0, 10], (20, 30] and (80, 90] and leave
|
||||
# (10, 20] and (30, 80] with nothing in them
|
||||
values = [2, 5, 8, 21, 22, 23, 24, 26, 28, 30, 82, 88]
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"host": f"host-{host:02d}"},
|
||||
timestamp=start,
|
||||
value=value,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for host, value in enumerate(values)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
int(start.timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[
|
||||
{
|
||||
"type": "clickhouse_sql",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": (
|
||||
"SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), INTERVAL 60 SECOND) AS ts, "
|
||||
"ceil(value / 10) * 10 - 10 AS `__bucket_min`, "
|
||||
"ceil(value / 10) * 10 AS `__bucket_max`, "
|
||||
"toFloat64(count()) AS `__result_0` "
|
||||
"FROM signoz_metrics.distributed_samples_v4 "
|
||||
f"WHERE metric_name = '{metric_name}' "
|
||||
"GROUP BY ts, `__bucket_min`, `__bucket_max`"
|
||||
),
|
||||
"disabled": False,
|
||||
},
|
||||
}
|
||||
],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
# 20 and 80 are on the axis although no row ended there, so the two ranges
|
||||
# the query skipped hold a count of their own rather than widening the
|
||||
# buckets above them
|
||||
assert get_heatmap_buckets(data, "A") == [10, 20, 30, 80, 90]
|
||||
# a clickhouse heatmap takes no bucketOptions, so the counts land exactly
|
||||
# where the query put them, plus the overflow slot
|
||||
assert [column["values"] for column in get_heatmap_columns(data, "A")] == [
|
||||
[3, 0, 7, 0, 2, 0],
|
||||
]
|
||||
|
||||
|
||||
def test_clickhouse_heatmap_with_no_rows(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], 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=2)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[
|
||||
{
|
||||
"type": "clickhouse_sql",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": (
|
||||
"SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), INTERVAL 60 SECOND) AS ts, "
|
||||
"toFloat64(0) AS `__bucket_min`, toFloat64(10) AS `__bucket_max`, toFloat64(count()) AS `__result_0` "
|
||||
"FROM signoz_metrics.distributed_samples_v4 "
|
||||
"WHERE metric_name = 'test_heatmap_clickhouse_never_written' "
|
||||
"GROUP BY ts"
|
||||
),
|
||||
"disabled": False,
|
||||
},
|
||||
}
|
||||
],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
assert get_heatmap_buckets(data, "A") == []
|
||||
assert get_heatmap_columns(data, "A") == []
|
||||
|
||||
|
||||
def test_cached_heatmap_matches_uncached(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
metric_name = "test_heatmap_cache"
|
||||
|
||||
# the first half sits 16x below the second, so the cached range and the fresh
|
||||
# one reach disjoint parts of the axis and neither may lose its counts
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "api"},
|
||||
timestamp=now - timedelta(minutes=60 - minute),
|
||||
value=256 if minute < 15 else 4096,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute in range(30)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = [build_builder_query("A", metric_name, "max", "max")]
|
||||
wide_start_ms = int((now - timedelta(minutes=60)).timestamp() * 1000)
|
||||
wide_end_ms = int((now - timedelta(minutes=30)).timestamp() * 1000)
|
||||
|
||||
warmup = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
wide_start_ms,
|
||||
int((now - timedelta(minutes=45)).timestamp() * 1000),
|
||||
query,
|
||||
request_type=RequestType.HEATMAP,
|
||||
no_cache=False,
|
||||
)
|
||||
assert warmup.status_code == HTTPStatus.OK, warmup.text
|
||||
|
||||
from_cache = make_query_request(signoz, token, wide_start_ms, wide_end_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
|
||||
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
|
||||
|
||||
uncached = make_query_request(signoz, token, wide_start_ms, wide_end_ms, query, request_type=RequestType.HEATMAP, no_cache=True)
|
||||
assert uncached.status_code == HTTPStatus.OK, uncached.text
|
||||
|
||||
assert_identical_query_response(from_cache, uncached)
|
||||
|
||||
# 256 and 4096 are 16x apart, which the axis covers at 16 buckets per 2x plus
|
||||
# the rung under the lowest, and every column holds the one value its minute
|
||||
# recorded
|
||||
assert len(get_heatmap_buckets(uncached.json(), "A")) == 66
|
||||
assert [sum(column["values"]) for column in get_heatmap_columns(uncached.json(), "A")] == [1] * 30
|
||||
|
||||
|
||||
def test_metric_with_no_data(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
missing_metric = "test_heatmap_metric_that_is_never_written"
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
int((now - timedelta(minutes=30)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_builder_query("A", missing_metric, "max", "max", bucket_options=build_log_bucket_options(0))],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
# a metric with nothing in the window carries no type to choose an axis
|
||||
# from, and that is an empty heatmap rather than a rejected request
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
data = response.json()
|
||||
assert get_heatmap_buckets(data, "A") == []
|
||||
assert get_heatmap_columns(data, "A") == []
|
||||
assert any(missing_metric in warning["message"] for warning in get_all_warnings(data))
|
||||
435
tests/integration/tests/queriermetrics/16_heatmap_rejections.py
Normal file
435
tests/integration/tests/queriermetrics/16_heatmap_rejections.py
Normal file
@@ -0,0 +1,435 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.fs import get_testdata_file_path
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.querier import (
|
||||
RequestType,
|
||||
build_builder_query,
|
||||
build_formula_query,
|
||||
build_function,
|
||||
build_log_bucket_options,
|
||||
get_error_message,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
HISTOGRAM_FILE = get_testdata_file_path("histogram_data_1h.jsonl")
|
||||
MINUTE_MS = 60_000
|
||||
|
||||
METRIC_NAME = "test_heatmap_rejections"
|
||||
QUERY = [build_builder_query("A", METRIC_NAME, "max", "max")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"queries, request_options, expected_message",
|
||||
[
|
||||
# a promql or clickhouse query cuts its own buckets, so neither spec has
|
||||
# anywhere to state an axis
|
||||
pytest.param(
|
||||
[{"type": "promql", "spec": {"name": "A", "query": METRIC_NAME, "bucketOptions": build_log_bucket_options(2)}}],
|
||||
{},
|
||||
'unknown field "bucketOptions" in PromQL spec',
|
||||
id="bucket_options_on_a_promql_query",
|
||||
),
|
||||
pytest.param(
|
||||
[{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT 1", "bucketOptions": build_log_bucket_options(2)}}],
|
||||
{},
|
||||
'unknown field "bucketOptions" in ClickHouse SQL spec',
|
||||
id="bucket_options_on_a_clickhouse_query",
|
||||
),
|
||||
pytest.param(
|
||||
QUERY,
|
||||
{"format_options": {"formatTableResultForUI": False, "fillGaps": True}},
|
||||
"fillGaps is not supported for heatmap requests",
|
||||
id="fill_gaps",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
build_builder_query("A", METRIC_NAME, "max", "max"),
|
||||
build_builder_query("B", METRIC_NAME, "min", "min"),
|
||||
],
|
||||
{},
|
||||
"a heatmap renders one distribution, but 2 queries are enabled",
|
||||
id="two_enabled_queries",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
build_builder_query("A", METRIC_NAME, "max", "max"),
|
||||
build_builder_query("B", METRIC_NAME, "min", "min", disabled=True),
|
||||
build_formula_query("F1", "B"),
|
||||
],
|
||||
{},
|
||||
"a heatmap renders one distribution, but 2 queries are enabled",
|
||||
id="a_formula_beside_an_enabled_query",
|
||||
),
|
||||
pytest.param(
|
||||
[build_builder_query("A", METRIC_NAME, "max", "max", disabled=True)],
|
||||
{},
|
||||
"a heatmap needs one enabled query, but every query is disabled",
|
||||
id="only_a_disabled_query",
|
||||
),
|
||||
# an empty body is refused while resources are extracted from it, before
|
||||
# anything heatmap specific runs, so match either wording of that message
|
||||
pytest.param(
|
||||
[],
|
||||
{},
|
||||
"one query is required",
|
||||
id="no_queries",
|
||||
),
|
||||
pytest.param(
|
||||
[build_builder_query("A", METRIC_NAME, "max", "max", functions=[build_function("absolute")])],
|
||||
{},
|
||||
"functions are not supported for heatmap requests",
|
||||
id="functions_on_the_query",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
build_builder_query("A", METRIC_NAME, "max", "max", disabled=True, functions=[build_function("absolute")]),
|
||||
build_formula_query("F1", "A"),
|
||||
],
|
||||
{},
|
||||
"functions are not supported for heatmap requests",
|
||||
id="functions_on_a_disabled_formula_input",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
build_builder_query("A", METRIC_NAME, "max", "max", disabled=True),
|
||||
build_formula_query("F1", "A", functions=[build_function("absolute")]),
|
||||
],
|
||||
{},
|
||||
"functions are not supported for heatmap requests",
|
||||
id="functions_on_the_formula",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": METRIC_NAME, "timeAggregation": "max", "spaceAggregation": "max"}],
|
||||
"stepInterval": 60,
|
||||
"having": {"expression": "value > 1"},
|
||||
},
|
||||
}
|
||||
],
|
||||
{},
|
||||
"having is not supported for heatmap requests",
|
||||
id="having_on_the_query",
|
||||
),
|
||||
pytest.param(
|
||||
[build_builder_query("A", METRIC_NAME, "max", "max", bucket_options={"kind": "quadratic", "spec": {}})],
|
||||
{},
|
||||
"invalid bucketOptions kind",
|
||||
id="unknown_bucket_kind",
|
||||
),
|
||||
pytest.param(
|
||||
[build_builder_query("A", METRIC_NAME, "max", "max", bucket_options={"kind": "log"})],
|
||||
{},
|
||||
"bucketOptions spec is required",
|
||||
id="log_without_a_spec",
|
||||
),
|
||||
pytest.param(
|
||||
[build_builder_query("A", METRIC_NAME, "max", "max", bucket_options={"kind": "linear"})],
|
||||
{},
|
||||
"bucketOptions spec is required",
|
||||
id="linear_without_a_spec",
|
||||
),
|
||||
# the query spec is strict-decoded, so it names itself rather than the
|
||||
# buckets spec the field is actually wrong in
|
||||
pytest.param(
|
||||
[build_builder_query("A", METRIC_NAME, "max", "max", bucket_options={"kind": "linear", "spec": {"maxValue": 1000, "scale": 2}})],
|
||||
{},
|
||||
'unknown field "scale" in query spec',
|
||||
id="scale_under_the_linear_kind",
|
||||
),
|
||||
pytest.param(
|
||||
[build_builder_query("A", METRIC_NAME, "max", "max", bucket_options={"kind": "log", "spec": {"scale": 5}})],
|
||||
{},
|
||||
"scale must be between -4 and 4",
|
||||
id="scale_above_the_maximum",
|
||||
),
|
||||
pytest.param(
|
||||
[build_builder_query("A", METRIC_NAME, "max", "max", bucket_options={"kind": "log", "spec": {"scale": -5}})],
|
||||
{},
|
||||
"scale must be between -4 and 4",
|
||||
id="scale_below_the_minimum",
|
||||
),
|
||||
pytest.param(
|
||||
[build_builder_query("A", METRIC_NAME, "max", "max", bucket_options={"kind": "linear", "spec": {"maxValue": 0}})],
|
||||
{},
|
||||
"linear buckets need a finite maxValue greater than 0",
|
||||
id="zero_max_value",
|
||||
),
|
||||
pytest.param(
|
||||
[build_builder_query("A", METRIC_NAME, "max", "max", bucket_options={"kind": "linear", "spec": {"maxValue": -10}})],
|
||||
{},
|
||||
"linear buckets need a finite maxValue greater than 0",
|
||||
id="negative_max_value",
|
||||
),
|
||||
pytest.param(
|
||||
[build_builder_query("A", METRIC_NAME, "max", "max", bucket_options={"kind": "linear", "spec": {"maxValue": 1000, "numBuckets": 513}})],
|
||||
{},
|
||||
"numBuckets must be between 1 and 512",
|
||||
id="too_many_buckets",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
build_builder_query("A", METRIC_NAME, "max", "max", disabled=True),
|
||||
build_formula_query("F1", "A", bucket_options={"kind": "log", "spec": {"scale": 5}}),
|
||||
],
|
||||
{},
|
||||
"scale must be between -4 and 4",
|
||||
id="scale_on_the_formula",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_heatmap_request_is_rejected(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
queries: list[dict],
|
||||
request_options: 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=30)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
queries,
|
||||
request_type=RequestType.HEATMAP,
|
||||
**request_options,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert expected_message in get_error_message(response.json())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"request_type",
|
||||
[RequestType.TIME_SERIES, RequestType.SCALAR, RequestType.RAW],
|
||||
ids=["time_series", "scalar", "raw"],
|
||||
)
|
||||
def test_bucket_options_outside_a_heatmap(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
request_type: 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=30)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_builder_query("A", METRIC_NAME, "max", "max", bucket_options=build_log_bucket_options(2))],
|
||||
request_type=request_type,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert "bucketOptions are only supported for heatmap requests" in get_error_message(response.json())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"columns",
|
||||
[
|
||||
pytest.param(
|
||||
[
|
||||
"toFloat64(20) AS `__bucket_max`, toFloat64(3) AS `__result_0`",
|
||||
"toFloat64(30) AS `__bucket_max`, toFloat64(7) AS `__result_0`",
|
||||
],
|
||||
id="upper_bound_without_a_lower_one",
|
||||
),
|
||||
pytest.param(
|
||||
["toFloat64(3) AS `__result_0`"],
|
||||
id="neither_bound",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_clickhouse_missing_bucket_columns_are_rejected(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
columns: list[str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start = now - timedelta(minutes=2)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
ts = f"toDateTime({int(start.timestamp())}) AS ts"
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
int(start.timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[
|
||||
{
|
||||
"type": "clickhouse_sql",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": " UNION ALL ".join(f"SELECT {ts}, {row}" for row in columns),
|
||||
"disabled": False,
|
||||
},
|
||||
}
|
||||
],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert 'a heatmap needs a "__bucket_min" and a "__bucket_max" column' in get_error_message(response.json())
|
||||
|
||||
|
||||
def test_clickhouse_one_bucket_cut_two_ways_is_rejected(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start = now - timedelta(minutes=2)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
ts = f"toDateTime({int(start.timestamp())}) AS ts"
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
int(start.timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[
|
||||
{
|
||||
"type": "clickhouse_sql",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": " UNION ALL ".join(f"SELECT {ts}, toFloat64({lower}) AS `__bucket_min`, toFloat64(20) AS `__bucket_max`, toFloat64(3) AS `__result_0`" for lower in (10, 12)),
|
||||
"disabled": False,
|
||||
},
|
||||
}
|
||||
],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
# both rows carry the same timestamp and no labels, so the two lower bounds
|
||||
# are all that tells them apart, in whichever order the union returns them
|
||||
at = start.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
assert get_error_message(response.json()) in (
|
||||
f"the bucket ending at 20 starts at 10 for {at} and at 12 for {at}",
|
||||
f"the bucket ending at 20 starts at 12 for {at} and at 10 for {at}",
|
||||
)
|
||||
|
||||
|
||||
def test_clickhouse_one_bucket_cut_two_ways_across_groups_is_rejected(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start = now - timedelta(minutes=2)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
ts = f"toDateTime({int(start.timestamp())}) AS ts"
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
int(start.timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[
|
||||
{
|
||||
"type": "clickhouse_sql",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": " UNION ALL ".join(f"SELECT {ts}, '{service}' AS service, toFloat64({lower}) AS `__bucket_min`, toFloat64(20) AS `__bucket_max`, toFloat64(3) AS `__result_0`" for service, lower in (("api", 10), ("web", 12))),
|
||||
"disabled": False,
|
||||
},
|
||||
}
|
||||
],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
# here it is the group that tells the two rows apart, so the error has to
|
||||
# name which one to go and fix
|
||||
at = start.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
assert get_error_message(response.json()) in (
|
||||
f"the bucket ending at 20 starts at 10 for service=api at {at} and at 12 for service=web at {at}",
|
||||
f"the bucket ending at 20 starts at 12 for service=web at {at} and at 10 for service=api at {at}",
|
||||
)
|
||||
|
||||
|
||||
def test_promql_returning_no_le_is_rejected(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
metric = f"promql_heatmap_gauge_{uuid4().hex[:8]}"
|
||||
end_ms = (int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000) // MINUTE_MS) * MINUTE_MS
|
||||
start_ms = end_ms - MINUTE_MS
|
||||
|
||||
# the metric has to return something, since an empty result is the window
|
||||
# having no data rather than a query that can never draw a heatmap
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric,
|
||||
labels={"service": "api"},
|
||||
timestamp=datetime.fromtimestamp(ts_ms / 1000, tz=UTC),
|
||||
value=42.0,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for ts_ms in range(start_ms, end_ms + 1, MINUTE_MS)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[{"type": "promql", "spec": {"name": "A", "query": metric, "step": 60}}],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert "no `le` labels to build a bucket axis from" in get_error_message(response.json())
|
||||
|
||||
|
||||
def test_histogram_rejects_bucket_options(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = "test_heatmap_histogram_with_bucket_options"
|
||||
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
HISTOGRAM_FILE,
|
||||
base_time=now - timedelta(minutes=60),
|
||||
metric_name_override=metric_name,
|
||||
)
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[build_builder_query("A", metric_name, "increase", "p50", bucket_options=build_log_bucket_options(2))],
|
||||
request_type=RequestType.HEATMAP,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert "bucketOptions are not supported for histogram metrics" in get_error_message(response.json())
|
||||
Reference in New Issue
Block a user