Compare commits

...

3 Commits

Author SHA1 Message Date
Naman Verma
16efc6a01c Merge branch 'main' into nv/distr-query 2026-11-03 16:51:14 +05:30
Naman Verma
d36c4ba843 fix: stringvalue function on request type 2026-11-03 16:50:55 +05:30
Naman Verma
85ef5e8e3f feat: add support for distribution query in query range 2026-11-03 15:15:30 +05:30
18 changed files with 515 additions and 202 deletions

View File

@@ -9565,9 +9565,9 @@ components:
Querybuildertypesv5AggregationMeta:
properties:
buckets:
description: The ascending upper bounds of the value bands, set only for
heatmap results; each point then carries one count per bound plus a trailing
overflow count in values.
description: The ascending upper bounds of the value buckets, set for heatmap
and distribution results; each point then carries one count per bound
plus a trailing overflow count in values.
items:
format: double
type: number
@@ -9722,6 +9722,56 @@ components:
nullable: true
type: array
type: object
Querybuildertypesv5Distribution:
properties:
counts:
description: One count per bound in meta.buckets plus a trailing overflow
count, summed over every step of the requested time range.
items:
format: double
type: number
nullable: true
type: array
labels:
description: The group-by field values that identify this distribution.
items:
$ref: '#/components/schemas/Querybuildertypesv5Label'
type: array
type: object
Querybuildertypesv5DistributionAggregation:
properties:
alias:
description: The alias of the aggregation, or the internal name __result_N,
with N its index, when no alias was given.
type: string
distributions:
description: One distribution per group-by combination.
items:
$ref: '#/components/schemas/Querybuildertypesv5Distribution'
nullable: true
type: array
index:
description: The zero-based position of the aggregation in the query.
type: integer
meta:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
description: Display metadata for the aggregation, including the bucket
upper bounds the counts are positional against.
type: object
Querybuildertypesv5DistributionData:
properties:
aggregations:
description: One entry per aggregation of the query. The order is not guaranteed;
match entries to aggregations by index.
items:
$ref: '#/components/schemas/Querybuildertypesv5DistributionAggregation'
nullable: true
type: array
queryName:
description: The name of the query that produced this result.
type: string
type: object
Querybuildertypesv5ExecStats:
description: 'Execution cost of the request: rows and bytes scanned and execution
time. A measure of cost, not of whether data exists.'
@@ -10545,10 +10595,12 @@ components:
- $ref: '#/components/schemas/Querybuildertypesv5TimeSeriesData'
- $ref: '#/components/schemas/Querybuildertypesv5ScalarData'
- $ref: '#/components/schemas/Querybuildertypesv5RawData'
- $ref: '#/components/schemas/Querybuildertypesv5DistributionData'
properties:
results:
description: 'One result per enabled query, typed by the request type: TimeSeriesData
for time_series, ScalarData for scalar and RawData for raw requests.'
for time_series and heatmap, ScalarData for scalar, RawData for raw and
DistributionData for distribution requests.'
items: {}
nullable: true
type: array
@@ -10738,8 +10790,9 @@ components:
type: object
Querybuildertypesv5QueryRangeResponse:
description: 'Response from the v5 query range endpoint. The data.results array
contains typed results depending on the requestType: TimeSeriesData for time_series,
ScalarData for scalar, or RawData for raw requests.'
contains typed results depending on the requestType: TimeSeriesData for time_series
and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for
distribution requests.'
properties:
data:
allOf:
@@ -10836,6 +10889,7 @@ components:
- raw_stream
- trace
- heatmap
- distribution
type: string
Querybuildertypesv5ScalarData:
properties:
@@ -10947,8 +11001,8 @@ components:
format: double
type: number
values:
description: The per-band counts for heatmap results, in the order of meta.buckets
plus a trailing overflow count.
description: The per-bucket counts for heatmap results, in the order of
meta.buckets plus a trailing overflow count.
items:
format: double
type: number

View File

@@ -6941,6 +6941,7 @@ export enum Querybuildertypesv5RequestTypeDTO {
raw_stream = 'raw_stream',
trace = 'trace',
heatmap = 'heatmap',
distribution = 'distribution',
}
/**
* The query kind.
@@ -10977,7 +10978,7 @@ export interface Querybuildertypesv5TimeSeriesValueDTO {
value?: number;
/**
* @type array
* @description The per-band counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
* @description The per-bucket counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
*/
values?: number[];
}
@@ -11598,7 +11599,7 @@ export interface PromotetypesPostableIndexMaterializedPathsDTO {
export interface Querybuildertypesv5AggregationMetaDTO {
/**
* @type array
* @description The ascending upper bounds of the value bands, set only for heatmap results; each point then carries one count per bound plus a trailing overflow count in values.
* @description The ascending upper bounds of the value buckets, set for heatmap and distribution results; each point then carries one count per bound plus a trailing overflow count in values.
*/
buckets?: number[];
/**
@@ -11719,6 +11720,54 @@ export interface Querybuildertypesv5ColumnDescriptorDTO {
unit?: string;
}
export interface Querybuildertypesv5DistributionDTO {
/**
* @type array,null
* @description One count per bound in meta.buckets plus a trailing overflow count, summed over every step of the requested time range.
*/
counts?: number[] | null;
/**
* @type array
* @description The group-by field values that identify this distribution.
*/
labels?: Querybuildertypesv5LabelDTO[];
}
export interface Querybuildertypesv5DistributionAggregationDTO {
/**
* @type string
* @description The alias of the aggregation, or the internal name __result_N, with N its index, when no alias was given.
*/
alias?: string;
/**
* @type array,null
* @description One distribution per group-by combination.
*/
distributions?: Querybuildertypesv5DistributionDTO[] | null;
/**
* @type integer
* @description The zero-based position of the aggregation in the query.
*/
index?: number;
/**
* @description Display metadata for the aggregation, including the bucket upper bounds the counts are positional against.
*/
meta?: Querybuildertypesv5AggregationMetaDTO;
}
export interface Querybuildertypesv5DistributionDataDTO {
/**
* @type array,null
* @description One entry per aggregation of the query. The order is not guaranteed; match entries to aggregations by index.
*/
aggregations?: Querybuildertypesv5DistributionAggregationDTO[] | null;
/**
* @type string
* @description The name of the query that produced this result.
*/
queryName?: string;
}
/**
* The step interval in seconds that was used for each query, keyed by query name.
*/
@@ -11974,21 +12023,28 @@ export type Querybuildertypesv5QueryDataDTO =
| (Querybuildertypesv5TimeSeriesDataDTO & {
/**
* @type array,null
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
*/
results?: unknown[] | null;
})
| (Querybuildertypesv5ScalarDataDTO & {
/**
* @type array,null
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
*/
results?: unknown[] | null;
})
| (Querybuildertypesv5RawDataDTO & {
/**
* @type array,null
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
*/
results?: unknown[] | null;
})
| (Querybuildertypesv5DistributionDataDTO & {
/**
* @type array,null
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
*/
results?: unknown[] | null;
});
@@ -12122,7 +12178,7 @@ export interface Querybuildertypesv5QueryRangeRequestDTO {
}
/**
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series, ScalarData for scalar, or RawData for raw requests.
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for distribution requests.
*/
export interface Querybuildertypesv5QueryRangeResponseDTO {
/**

View File

@@ -481,8 +481,7 @@ func (bc *bucketCache) mergeBuckets(ctx context.Context, buckets []*qbtypes.Cach
// Merge values based on type
var mergedValue any
switch resultType {
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
if resultType.IsBuiltFromTimeSeries() {
mergedValue = bc.mergeTimeSeriesValues(ctx, buckets)
// Raw and Scalar types are not cached, so no merge needed
}
@@ -629,8 +628,8 @@ func (bc *bucketCache) isEmptyResult(result *qbtypes.Result) (isEmpty bool, isFi
return true, false
}
switch result.Type {
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
switch {
case result.Type.IsBuiltFromTimeSeries():
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
// No aggregations at all means truly empty
if len(tsData.Aggregations) == 0 {
@@ -665,7 +664,7 @@ func (bc *bucketCache) isEmptyResult(result *qbtypes.Result) (isEmpty bool, isFi
return !hasValues, !hasValues && totalSeries > 0
}
case qbtypes.RequestTypeRaw, qbtypes.RequestTypeScalar, qbtypes.RequestTypeTrace:
default:
// Raw and scalar data are not cached
return true, false
}
@@ -768,8 +767,8 @@ func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, fluxBoun
Warnings: result.Warnings,
}
switch result.Type {
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
switch {
case result.Type.IsBuiltFromTimeSeries():
// Trim time series data
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok && tsData != nil {
trimmedData := &qbtypes.TimeSeriesData{}
@@ -812,7 +811,7 @@ func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, fluxBoun
trimmedResult.Value = trimmedData
}
case qbtypes.RequestTypeRaw, qbtypes.RequestTypeScalar, qbtypes.RequestTypeTrace:
default:
// Don't cache raw or scalar data
return nil
}
@@ -854,8 +853,8 @@ func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, q qbtypes
}
}
switch result.Type {
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
switch {
case result.Type.IsBuiltFromTimeSeries():
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
filteredData := &qbtypes.TimeSeriesData{
Aggregations: make([]*qbtypes.AggregationBucket, 0, len(tsData.Aggregations)),

View File

@@ -428,10 +428,10 @@ func (q *builderQuery[T]) narrowWindowByTraceID(ctx context.Context, fromMS, toM
// emptyResultFor returns an empty result payload appropriate for the given kind.
func emptyResultFor(kind qbtypes.RequestType, queryName string) *qbtypes.Result {
var value any
switch kind {
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
switch {
case kind.IsBuiltFromTimeSeries():
value = &qbtypes.TimeSeriesData{QueryName: queryName}
case qbtypes.RequestTypeScalar:
case kind == qbtypes.RequestTypeScalar:
value = &qbtypes.ScalarData{QueryName: queryName}
default:
value = &qbtypes.RawData{QueryName: queryName}
@@ -484,7 +484,7 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
kind := q.kind
// 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 {
if q.spec.Signal == telemetrytypes.SignalMetrics && !kind.CountsIntoBuckets() {
kind = qbtypes.RequestTypeTimeSeries
}

View File

@@ -82,7 +82,7 @@ func labelValue(val any) string {
// * Time-series - *qbtypes.TimeSeriesData
// * Scalar - *qbtypes.ScalarData
// * Raw - *qbtypes.RawData
// * Distribution- *qbtypes.DistributionData.
// * Heatmap and distribution - *qbtypes.TimeSeriesData with one count per bucket at each point.
func consume(rows driver.Rows, kind qbtypes.RequestType, queryWindow *qbtypes.TimeRange, step qbtypes.Step, queryName string) (any, error) {
var (
payload any
@@ -94,7 +94,7 @@ 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:
case qbtypes.RequestTypeHeatmap, qbtypes.RequestTypeDistribution:
payload, err = readAsHeatmap(rows, queryWindow, step, queryName)
case qbtypes.RequestTypeRaw, qbtypes.RequestTypeTrace, qbtypes.RequestTypeRawStream:
payload, err = readAsRaw(rows, queryName)

View File

@@ -98,6 +98,15 @@ func (q *querier) postProcessResults(ctx context.Context, orgID valuer.UUID, res
// Filter out disabled queries
typedResults = q.filterDisabledQueries(typedResults, req)
// Summed only once the bucket axis is final.
if req.RequestType == qbtypes.RequestTypeDistribution {
for _, result := range typedResults {
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
result.Value = tsData.ToDistributionData()
}
}
}
// Apply table formatting for UI if requested
if req.FormatOptions != nil && req.FormatOptions.FormatTableResultForUI && req.RequestType == qbtypes.RequestTypeScalar {
@@ -225,7 +234,7 @@ func postProcessMetricQuery(
}
}
if req.RequestType == qbtypes.RequestTypeHeatmap && config.HeatmapBucketing != nil {
if req.RequestType.CountsIntoBuckets() && config.HeatmapBucketing != nil {
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
resolveHeatmapBucketAxis(tsData, *config.HeatmapBucketing)
}
@@ -360,7 +369,7 @@ 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:
case qbtypes.RequestTypeHeatmap, qbtypes.RequestTypeDistribution:
// 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)

View File

@@ -134,9 +134,7 @@ func newPromqlQuery(
}
func (q *promqlQuery) Fingerprint() string {
switch q.requestType {
case qbv5.RequestTypeTimeSeries, qbv5.RequestTypeHeatmap:
default:
if !q.requestType.IsBuiltFromTimeSeries() {
return ""
}
@@ -358,7 +356,7 @@ func collectExecStats(began time.Time, statsMu *sync.Mutex, rowsScanned, bytesSc
}
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 {
if q.requestType.CountsIntoBuckets() {
return q.toResultForHeatmap(matrix, warnings, began, statsMu, rowsScanned, bytesScanned)
}
return q.toResultForTimeSeriesAndScalar(matrix, warnings, began, statsMu, rowsScanned, bytesScanned), nil

View File

@@ -151,12 +151,12 @@ 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, qbtypes.RequestTypeHeatmap:
switch {
case req.RequestType.IsBuiltFromTimeSeries():
preseededResults[name] = &qbtypes.TimeSeriesData{QueryName: name}
case qbtypes.RequestTypeScalar:
case req.RequestType == qbtypes.RequestTypeScalar:
preseededResults[name] = &qbtypes.ScalarData{QueryName: name}
case qbtypes.RequestTypeRaw:
case req.RequestType == qbtypes.RequestTypeRaw:
preseededResults[name] = &qbtypes.RawData{QueryName: name}
}
}
@@ -281,7 +281,7 @@ func (q *querier) buildQueries(
continue
}
requestType := req.RequestType
if requestType == qbtypes.RequestTypeHeatmap && spec.Disabled {
if requestType.CountsIntoBuckets() && spec.Disabled {
// A disabled query in a heatmap request feeds a formula, and the
// formula converts time series into heatmap data, so its inputs
// run as time series queries.
@@ -432,7 +432,7 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
}
// 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 requestType.CountsIntoBuckets() && !spec.Disabled {
if err := spec.Aggregations[i].VerifyAndApplyBucketOptions(spec.BucketOptions); err != nil {
return nil, nil, err
}
@@ -655,16 +655,16 @@ func (q *querier) run(
if result == nil || result.Value == nil {
return false
}
switch result.Type {
case qbtypes.RequestTypeScalar:
switch {
case result.Type == qbtypes.RequestTypeScalar:
if val, ok := result.Value.(*qbtypes.ScalarData); ok && val != nil {
return len(val.Data) != 0
}
case qbtypes.RequestTypeRaw:
case result.Type == qbtypes.RequestTypeRaw:
if val, ok := result.Value.(*qbtypes.RawData); ok && val != nil {
return len(val.Rows) != 0
}
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
case result.Type.IsBuiltFromTimeSeries():
if val, ok := result.Value.(*qbtypes.TimeSeriesData); ok && val != nil {
if len(val.Aggregations) != 0 {
anyNonEmpty := false
@@ -984,8 +984,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, qbtypes.RequestTypeHeatmap:
if merged.Type.IsBuiltFromTimeSeries() {
// Pass nil as cached value to ensure proper merging of all fresh results
merged.Value = q.mergeTimeSeriesResults(nil, fresh)
}
@@ -1007,8 +1006,7 @@ func (q *querier) mergeResults(cached *qbtypes.Result, fresh []*qbtypes.Result)
return merged
}
switch merged.Type {
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
if merged.Type.IsBuiltFromTimeSeries() {
merged.Value = q.mergeTimeSeriesResults(cached.Value.(*qbtypes.TimeSeriesData), fresh)
}

View File

@@ -264,7 +264,7 @@ func rewriteQueryForHistogramCTE(requestType qbtypes.RequestType, query qbtypes.
})
query.Aggregations = slices.Clone(query.Aggregations)
if query.Aggregations[0].SpaceAggregation.IsPercentile() && requestType != qbtypes.RequestTypeHeatmap {
if query.Aggregations[0].SpaceAggregation.IsPercentile() && !requestType.CountsIntoBuckets() {
query.Aggregations[0].TimeAggregation = metrictypes.TimeAggregationRate
} else {
query.Aggregations[0].TimeAggregation = metrictypes.TimeAggregationIncrease
@@ -818,7 +818,7 @@ func (b *StatementBuilder) BuildFinalSelect(
args = append(args, a...)
}
if requestType == qbtypes.RequestTypeHeatmap {
if requestType.CountsIntoBuckets() {
return buildHeatmapFinalSelect(combined, args, query)
}
return buildAggregationFinalSelect(combined, args, query)

View File

@@ -526,7 +526,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
}
}
// TestHeatmapPanelQueryKinds pins the panel allowlist to what validateHeatmap
// TestHeatmapPanelQueryKinds pins the panel allowlist to what validateBucketCounting
// accepts in querybuildertypesv5: everything but a trace operator.
func TestHeatmapPanelQueryKinds(t *testing.T) {
testCases := []struct {

View File

@@ -81,7 +81,7 @@ func (b *BucketOptions) ToHeatmapBucketing() HeatmapBucketing {
return resolved
}
// This cannot be called in validateHeatmap cuz metric type is resolved in querier.go.
// This cannot be called in validateBucketCounting cuz metric type is resolved in querier.go.
func (a *MetricAggregation) VerifyAndApplyBucketOptions(bucketOptions *BucketOptions) error {
switch a.Type {
case metrictypes.HistogramType:
@@ -298,6 +298,31 @@ func addHeatmapBucketsWithNoCountsForAggregation(aggBucket *AggregationBucket, b
aggBucket.Meta.Buckets = denseUpperBounds
}
// ToDistributionData sums every series over all its steps, keeping the bucket
// axis. A partial step counts too, since what it holds was observed.
func (t *TimeSeriesData) ToDistributionData() *DistributionData {
data := &DistributionData{QueryName: t.QueryName, Aggregations: make([]*DistributionAggregation, 0, len(t.Aggregations))}
for _, aggBucket := range t.Aggregations {
if aggBucket == nil {
continue
}
aggregation := &DistributionAggregation{Index: aggBucket.Index, Alias: aggBucket.Alias, Meta: aggBucket.Meta, Distributions: make([]*Distribution, 0, len(aggBucket.Series))}
for _, series := range aggBucket.Series {
counts := make([]float64, len(aggBucket.Meta.Buckets)+1)
for _, point := range series.Values {
for i, count := range point.Values {
if i < len(counts) {
counts[i] += count
}
}
}
aggregation.Distributions = append(aggregation.Distributions, &Distribution{Labels: series.Labels, Counts: counts})
}
data.Aggregations = append(data.Aggregations, aggregation)
}
return data
}
// 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,

View File

@@ -39,7 +39,8 @@ var (
RequestTypeRawStream = RequestType{valuer.NewString("raw_stream")}
// [][]any, Specialized SQL result set, paginated.
RequestTypeTrace = RequestType{valuer.NewString("trace")}
// []Bucket (struct{Lower,Upper,Count float64}), example: histogram.
// DistributionData: a heatmap summed over its steps, one set of counts per
// group against the aggregation's bucket upper bounds, 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.
@@ -55,6 +56,14 @@ func (r RequestType) IsAggregation() bool {
return r == RequestTypeTimeSeries || r == RequestTypeScalar || r == RequestTypeDistribution || r == RequestTypeHeatmap
}
func (r RequestType) CountsIntoBuckets() bool {
return r == RequestTypeHeatmap || r == RequestTypeDistribution
}
func (r RequestType) IsBuiltFromTimeSeries() bool {
return r == RequestTypeTimeSeries || r.CountsIntoBuckets()
}
// Enum implements jsonschema.Enum; returns the acceptable values for RequestType.
func (RequestType) Enum() []any {
return []any{
@@ -64,6 +73,6 @@ func (RequestType) Enum() []any {
RequestTypeRawStream,
RequestTypeTrace,
RequestTypeHeatmap,
// RequestTypeDistribution,
RequestTypeDistribution,
}
}

View File

@@ -42,7 +42,7 @@ type QueryWarnDataAdditional struct {
}
type QueryData struct {
Results []any `json:"results" description:"One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests."`
Results []any `json:"results" description:"One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests."`
}
var _ jsonschema.OneOfExposer = QueryData{}
@@ -53,6 +53,7 @@ func (QueryData) JSONSchemaOneOf() []any {
TimeSeriesData{},
ScalarData{},
RawData{},
DistributionData{},
}
}
@@ -128,7 +129,7 @@ var _ jsonschema.Preparer = &QueryRangeResponse{}
// PrepareJSONSchema adds description to the QueryRangeResponse schema.
func (q *QueryRangeResponse) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.WithDescription("Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series, ScalarData for scalar, or RawData for raw requests.")
schema.WithDescription("Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for distribution requests.")
return nil
}
@@ -258,7 +259,7 @@ func (a *AggregationBucket) TrimAxisToCountedBuckets() {
type AggregationMeta struct {
Unit string `json:"unit,omitempty" description:"The unit of the aggregated values, taken from metric metadata when known."`
Buckets []float64 `json:"buckets,omitempty" description:"The ascending upper bounds of the value bands, set only for heatmap results; each point then carries one count per bound plus a trailing overflow count in values."`
Buckets []float64 `json:"buckets,omitempty" description:"The ascending upper bounds of the value buckets, set for heatmap and distribution results; each point then carries one count per bound plus a trailing overflow count in values."`
}
type TimeSeries struct {
@@ -375,7 +376,26 @@ type TimeSeriesValue struct {
Partial bool `json:"partial,omitempty" description:"True when the step is only partly covered by the requested time range, so the value is incomplete."`
Values []float64 `json:"values,omitempty" description:"The per-band counts for heatmap results, in the order of meta.buckets plus a trailing overflow count."`
Values []float64 `json:"values,omitempty" description:"The per-bucket counts for heatmap results, in the order of meta.buckets plus a trailing overflow count."`
}
// DistributionData is a heatmap summed over its steps: one set of counts per
// group, positional against the aggregation's bucket upper bounds.
type DistributionData struct {
QueryName string `json:"queryName" description:"The name of the query that produced this result."`
Aggregations []*DistributionAggregation `json:"aggregations" description:"One entry per aggregation of the query. The order is not guaranteed; match entries to aggregations by index."`
}
type DistributionAggregation struct {
Index int `json:"index" description:"The zero-based position of the aggregation in the query."`
Alias string `json:"alias" description:"The alias of the aggregation, or the internal name __result_N, with N its index, when no alias was given."`
Meta AggregationMeta `json:"meta,omitempty" description:"Display metadata for the aggregation, including the bucket upper bounds the counts are positional against."`
Distributions []*Distribution `json:"distributions" description:"One distribution per group-by combination."`
}
type Distribution struct {
Labels []*Label `json:"labels,omitempty" description:"The group-by field values that identify this distribution."`
Counts []float64 `json:"counts" description:"One count per bound in meta.buckets plus a trailing overflow count, summed over every step of the requested time range."`
}
type ColumnType struct {

View File

@@ -584,7 +584,7 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
// Validate request type
switch r.RequestType {
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap, RequestTypeDistribution:
opts = append(opts, GetValidationOptions(r.RequestType)...)
default:
return errors.NewInvalidInputf(
@@ -592,11 +592,11 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
"invalid request type: %s",
r.RequestType,
).WithAdditional(
"Valid request types are: raw, timeseries, scalar, heatmap",
"Valid request types are: raw, timeseries, scalar, heatmap, distribution",
)
}
if err := r.validateHeatmap(); err != nil {
if err := r.validateBucketCounting(); err != nil {
return err
}
@@ -637,14 +637,14 @@ func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
var opts []ValidationOption
switch r.RequestType {
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap, RequestTypeDistribution:
opts = GetValidationOptions(r.RequestType)
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
WithAdditional("Valid request types are: raw, timeseries, scalar, heatmap")
WithAdditional("Valid request types are: raw, timeseries, scalar, heatmap, distribution")
}
if err := r.validateHeatmap(); err != nil {
if err := r.validateBucketCounting(); err != nil {
return nil, err
}
@@ -865,14 +865,14 @@ func validateQueryEnvelope(envelope QueryEnvelope, opts ...ValidationOption) err
}
}
func (r *QueryRangeRequest) validateHeatmap() error {
if r.RequestType != RequestTypeHeatmap {
func (r *QueryRangeRequest) validateBucketCounting() error {
if !r.RequestType.CountsIntoBuckets() {
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,
"bucketOptions are only supported for heatmap and distribution requests, got %s",
r.RequestType.StringValue(),
)
}
}
@@ -881,7 +881,7 @@ func (r *QueryRangeRequest) validateHeatmap() error {
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")
"fillGaps is not supported for %s requests: an absent column means collection stopped, which a zero-filled column would hide", r.RequestType.StringValue())
}
enabled := 0
@@ -892,7 +892,7 @@ func (r *QueryRangeRequest) validateHeatmap() error {
switch spec := envelope.Spec.(type) {
case QueryBuilderQuery[MetricAggregation]:
if err := validateHeatmapQuery(spec.Functions, spec.Having); err != nil {
if err := validateBucketCountingQuery(r.RequestType, spec.Functions, spec.Having); err != nil {
return err
}
if spec.Disabled {
@@ -900,7 +900,7 @@ func (r *QueryRangeRequest) validateHeatmap() error {
}
enabled++
case QueryBuilderFormula:
if err := validateHeatmapQuery(spec.Functions, spec.Having); err != nil {
if err := validateBucketCountingQuery(r.RequestType, spec.Functions, spec.Having); err != nil {
return err
}
if spec.Disabled {
@@ -925,7 +925,7 @@ func (r *QueryRangeRequest) validateHeatmap() error {
"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())
"%s requests support one metrics builder query, one formula over them, one clickhouse query, or one promql query, got %q", r.RequestType.StringValue(), envelope.Type.StringValue())
}
}
@@ -937,11 +937,11 @@ func (r *QueryRangeRequest) validateHeatmap() error {
"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").
"a %s needs one enabled query, but every query is disabled", r.RequestType.StringValue()).
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).
"%s requests take one enabled query, but %d are enabled", r.RequestType.StringValue(), 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",
@@ -1011,18 +1011,19 @@ func (b *BucketOptions) validateBucketOptions() error {
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 {
// validateBucketCountingQuery refuses the per-query settings that cannot mean
// anything once values are counted into buckets. It runs on disabled queries
// too: a disabled query is a formula input, so whatever it does still reaches
// the counts.
func validateBucketCountingQuery(requestType RequestType, 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")
"functions are not supported for %s requests: a heatmap point is a count per bucket, not a single value", requestType.StringValue())
}
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")
"having is not supported for %s requests: it filters individual cells, which breaks the cumulative differencing", requestType.StringValue())
}
return nil
@@ -1030,7 +1031,7 @@ func validateHeatmapQuery(functions []Function, having *Having) error {
func GetValidationOptions(requestType RequestType) []ValidationOption {
switch requestType {
case RequestTypeTimeSeries, RequestTypeHeatmap:
case RequestTypeTimeSeries, RequestTypeHeatmap, RequestTypeDistribution:
return []ValidationOption{WithSkipSelectFieldValidation(), WithTimestampGroupByValidation()}
case RequestTypeScalar:
return []ValidationOption{WithSkipSelectFieldValidation(), WithReduceToValidation()}

View File

@@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from typing import Any
import pytest
import requests
from fixtures import types
@@ -34,6 +35,7 @@ class RequestType:
SCALAR = "scalar"
TABLE = "table"
HEATMAP = "heatmap"
DISTRIBUTION = "distribution"
@dataclass
@@ -413,9 +415,9 @@ 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."""
def get_buckets(response_json: dict, query_name: str) -> list[float]:
"""The ascending bucket upper bounds a heatmap or distribution result's counts are positional against.
A point or distribution 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:
@@ -888,6 +890,51 @@ def assert_identical_query_response(response1: requests.Response, response2: req
assert response1.json()["data"]["data"]["results"] == response2.json()["data"]["data"]["results"], "Response data do not match"
def assert_distribution_sums_heatmap(
signoz: types.SigNoz,
token: str,
start_ms: int,
end_ms: int,
queries: list[dict],
heatmap: requests.Response,
*,
no_cache: bool = True,
) -> None:
"""A distribution request over the queries a heatmap was drawn from answers with that heatmap's
columns added up: the same aggregations and buckets, and for each series one count per bucket
plus the overflow under the same labels, with nothing time-shaped."""
response = make_query_request(signoz, token, start_ms, end_ms, queries, request_type=RequestType.DISTRIBUTION, no_cache=no_cache)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()
assert data["data"]["type"] == RequestType.DISTRIBUTION, f"response type: {data['data']['type']}"
def labels_of(item: dict) -> tuple:
return tuple(sorted((label["key"]["name"], str(label["value"])) for label in item.get("labels") or []))
heatmap_results = {result["queryName"]: result for result in heatmap.json()["data"]["data"]["results"]}
distribution_results = {result["queryName"]: result for result in data["data"]["data"]["results"]}
assert set(distribution_results) == set(heatmap_results), f"distribution results {sorted(distribution_results)}, heatmap results {sorted(heatmap_results)}"
for name, heatmap_result in heatmap_results.items():
heatmap_aggregations = heatmap_result.get("aggregations") or []
distribution_aggregations = distribution_results[name].get("aggregations") or []
assert [(aggregation["index"], aggregation["alias"], aggregation.get("meta", {})) for aggregation in distribution_aggregations] == [(aggregation["index"], aggregation["alias"], aggregation.get("meta", {})) for aggregation in heatmap_aggregations], (
f"{name}: distribution and heatmap aggregations differ in index, alias or meta"
)
for heatmap_aggregation, distribution_aggregation in zip(heatmap_aggregations, distribution_aggregations):
buckets = heatmap_aggregation.get("meta", {}).get("buckets", [])
series_by_labels = {labels_of(series): series for series in heatmap_aggregation.get("series") or []}
distributions_by_labels = {labels_of(distribution): distribution for distribution in distribution_aggregation.get("distributions") or []}
assert set(distributions_by_labels) == set(series_by_labels), f"{name}: distribution groups {sorted(distributions_by_labels)}, heatmap series {sorted(series_by_labels)}"
for labels, series in series_by_labels.items():
distribution = distributions_by_labels[labels]
assert set(distribution) <= {"labels", "counts"}, f"{name} {dict(labels)}: distribution fields {sorted(distribution)}"
summed = [sum(column["values"][slot] for column in series.get("values") or [] if slot < len(column.get("values") or [])) for slot in range(len(buckets) + 1)]
assert distribution["counts"] == pytest.approx(summed), f"{name} {dict(labels)}: distribution counts {distribution['counts']}, heatmap columns add up to {summed}"
# we already create the evolution for resource during schema migration
# since we have to create test data around it, we need to get the evolution time
def get_resource_evolution_time(signoz: types.SigNoz, signal: str) -> datetime:

View File

@@ -11,6 +11,7 @@ from fixtures.meter import MeterSample
from fixtures.metrics import Metrics
from fixtures.querier import (
RequestType,
assert_distribution_sums_heatmap,
assert_identical_query_response,
build_builder_query,
build_formula_query,
@@ -18,7 +19,7 @@ from fixtures.querier import (
build_log_bucket_options,
get_all_series,
get_all_warnings,
get_heatmap_buckets,
get_buckets,
get_heatmap_columns,
index_series_by_label,
make_query_request,
@@ -58,12 +59,13 @@ def test_gauge_heatmap(
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
queries = [build_builder_query("A", metric_name, "max", "max", group_by=["host"], bucket_options=build_log_bucket_options(0))]
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))],
queries,
request_type=RequestType.HEATMAP,
)
assert response.status_code == HTTPStatus.OK, response.text
@@ -72,7 +74,7 @@ def test_gauge_heatmap(
# 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])
assert get_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)
@@ -96,6 +98,8 @@ def test_gauge_heatmap(
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]
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
def test_sum_heatmap(
signoz: types.SigNoz,
@@ -126,18 +130,19 @@ def test_sum_heatmap(
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
queries = [build_builder_query("A", metric_name, "max", "max", temporality="cumulative", group_by=["endpoint"], bucket_options=build_log_bucket_options(0))]
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))],
queries,
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])
assert get_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)
@@ -154,6 +159,8 @@ def test_sum_heatmap(
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]
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
def test_meter_heatmap(
signoz: types.SigNoz,
@@ -183,12 +190,13 @@ def test_meter_heatmap(
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
queries = [build_builder_query("A", metric_name, "sum", "sum", source="meter", temporality="delta", step_interval=3600, bucket_options=build_linear_bucket_options(1000, 10))]
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
[build_builder_query("A", metric_name, "sum", "sum", source="meter", temporality="delta", step_interval=3600, bucket_options=build_linear_bucket_options(1000, 10))],
queries,
request_type=RequestType.HEATMAP,
)
assert response.status_code == HTTPStatus.OK, response.text
@@ -197,13 +205,15 @@ def test_meter_heatmap(
# 1000 split into 10 buckets gives (0, 100], (100, 200], ... (900, 1000]:
# 100 lands in the first, 250 in the third, and 1500 is past 1000 so it
# counts in the overflow
assert get_heatmap_buckets(data, "A") == pytest.approx([0.0, 100.0, 200.0, 300.0])
assert get_buckets(data, "A") == pytest.approx([0.0, 100.0, 200.0, 300.0])
assert [column["values"] for column in get_heatmap_columns(data, "A")] == [
[0, 1, 0, 0, 0],
[0, 0, 0, 1, 0],
[0, 0, 0, 0, 1],
]
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
def test_histogram_heatmap(
signoz: types.SigNoz,
@@ -225,12 +235,13 @@ def test_histogram_heatmap(
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
queries = [build_builder_query("A", metric_name, "increase", "p50", group_by=["le"], filter_expression='endpoint = "/health"')]
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"')],
queries,
request_type=RequestType.HEATMAP,
)
assert response.status_code == HTTPStatus.OK, response.text
@@ -239,14 +250,21 @@ def test_histogram_heatmap(
# 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]
assert get_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)
# the file records /health for an hour, one sample a minute, and each `le`
# counter grows by one more per minute than the one below it. the first
# sample has no increase, so over the other 59 minutes the 1000 counter
# grows by 11 through 69, the sum of which is 2360, and every bucket above
# it holds exactly one observation a minute, 59 in all
assert [sum(column["values"][slot] for column in columns) for slot in range(8)] == [2360, 59, 59, 59, 59, 59, 59, 59]
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
def test_linear_buckets(
@@ -279,12 +297,13 @@ def test_linear_buckets(
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
queries = [build_builder_query("A", metric_name, "max", "max", bucket_options=build_linear_bucket_options(1000, 10))]
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))],
queries,
request_type=RequestType.HEATMAP,
)
assert response.status_code == HTTPStatus.OK, response.text
@@ -292,7 +311,7 @@ def test_linear_buckets(
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])
assert get_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] == [
@@ -301,6 +320,8 @@ def test_linear_buckets(
[0, 0, 0, 0, 1],
]
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
@pytest.mark.parametrize(
"bucket_options, expected_buckets, expected_columns",
@@ -348,22 +369,25 @@ def test_zero_bucket(
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
queries = [build_builder_query("A", metric_name, "max", "max", bucket_options=bucket_options)]
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
[build_builder_query("A", metric_name, "max", "max", bucket_options=bucket_options)],
queries,
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)
assert get_buckets(data, "A") == pytest.approx(expected_buckets)
columns = get_heatmap_columns(data, "A")
assert [column["values"] for column in columns] == expected_columns
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
def test_single_bucket(
signoz: types.SigNoz,
@@ -392,12 +416,13 @@ def test_single_bucket(
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
queries = [build_builder_query("A", metric_name, "max", "max")]
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
[build_builder_query("A", metric_name, "max", "max")],
queries,
request_type=RequestType.HEATMAP,
)
assert response.status_code == HTTPStatus.OK, response.text
@@ -405,13 +430,15 @@ def test_single_bucket(
# 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 get_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],
]
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
# 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
@@ -458,18 +485,19 @@ def test_bucket_option_limits(
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
queries = [build_builder_query("A", metric_name, "max", "max", bucket_options=bucket_options)]
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
[build_builder_query("A", metric_name, "max", "max", bucket_options=bucket_options)],
queries,
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)
assert get_buckets(data, "A") == pytest.approx(expected_buckets)
columns = get_heatmap_columns(data, "A")
assert len(columns) == len(values)
@@ -477,6 +505,8 @@ def test_bucket_option_limits(
assert len(column["values"]) == len(expected_buckets) + 1
assert sum(column["values"]) == 1
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
def test_bucket_options_come_from_the_enabled_query(
signoz: types.SigNoz,
@@ -505,15 +535,16 @@ def test_bucket_options_come_from_the_enabled_query(
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
queries = [
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)),
]
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)),
],
queries,
request_type=RequestType.HEATMAP,
)
assert response.status_code == HTTPStatus.OK, response.text
@@ -521,12 +552,14 @@ def test_bucket_options_come_from_the_enabled_query(
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 get_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],
]
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
def test_formula_heatmap(
signoz: types.SigNoz,
@@ -568,22 +601,25 @@ def test_formula_heatmap(
# 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
queries = [
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)),
]
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)),
],
queries,
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 get_buckets(from_formula.json(), "F1") == pytest.approx(get_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")]
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, from_formula)
def test_promql_heatmap(
signoz: types.SigNoz,
@@ -601,18 +637,19 @@ def test_promql_heatmap(
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)
queries = [{"type": "promql", "spec": {"name": "A", "query": "sum by (le) (increase(heatmap_request_duration_bucket[2m]))", "step": 60}}]
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}}],
queries,
request_type=RequestType.HEATMAP,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()
assert get_heatmap_buckets(data, "A") == [1, 2, 4]
assert get_buckets(data, "A") == [1, 2, 4]
series = get_all_series(data, "A")
assert len(series) == 1
@@ -628,6 +665,8 @@ def test_promql_heatmap(
[6, 0, 8, 4],
]
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
def test_promql_heatmap_with_no_data(
signoz: types.SigNoz,
@@ -638,12 +677,13 @@ def test_promql_heatmap_with_no_data(
start_ms = end_ms - MINUTE_MS
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
queries = [{"type": "promql", "spec": {"name": "A", "query": "sum by (le) (increase(promql_heatmap_bucket_never_written[2m]))", "step": 60}}]
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}}],
queries,
request_type=RequestType.HEATMAP,
)
# a window holding nothing is not a query that can never draw a heatmap, so
@@ -651,9 +691,11 @@ def test_promql_heatmap_with_no_data(
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()
assert get_heatmap_buckets(data, "A") == []
assert get_buckets(data, "A") == []
assert get_heatmap_columns(data, "A") == []
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
def test_clickhouse_heatmap(
signoz: types.SigNoz,
@@ -683,29 +725,30 @@ def test_clickhouse_heatmap(
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
queries = [
{
"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,
},
}
]
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,
},
}
],
queries,
request_type=RequestType.HEATMAP,
)
assert response.status_code == HTTPStatus.OK, response.text
@@ -714,13 +757,15 @@ def test_clickhouse_heatmap(
# 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]
assert get_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],
]
assert_distribution_sums_heatmap(signoz, token, int(start.timestamp() * 1000), int(now.timestamp() * 1000), queries, response)
def test_clickhouse_heatmap_with_no_rows(
signoz: types.SigNoz,
@@ -728,37 +773,42 @@ def test_clickhouse_heatmap_with_no_rows(
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=2)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
queries = [
{
"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,
},
}
]
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,
},
}
],
start_ms,
end_ms,
queries,
request_type=RequestType.HEATMAP,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()
assert get_heatmap_buckets(data, "A") == []
assert get_buckets(data, "A") == []
assert get_heatmap_columns(data, "A") == []
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
def test_cached_heatmap_matches_uncached(
signoz: types.SigNoz,
@@ -812,9 +862,11 @@ def test_cached_heatmap_matches_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 len(get_buckets(uncached.json(), "A")) == 66
assert [sum(column["values"]) for column in get_heatmap_columns(uncached.json(), "A")] == [1] * 30
assert_distribution_sums_heatmap(signoz, token, wide_start_ms, wide_end_ms, query, uncached)
def test_metric_with_no_data(
signoz: types.SigNoz,
@@ -822,16 +874,19 @@ def test_metric_with_no_data(
get_token: Callable[[str, str], str],
) -> 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)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
missing_metric = "test_heatmap_metric_that_is_never_written"
queries = [build_builder_query("A", missing_metric, "max", "max", bucket_options=build_log_bucket_options(0))]
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))],
start_ms,
end_ms,
queries,
request_type=RequestType.HEATMAP,
)
# a metric with nothing in the window carries no type to choose an axis
@@ -839,10 +894,12 @@ def test_metric_with_no_data(
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()
assert get_heatmap_buckets(data, "A") == []
assert get_buckets(data, "A") == []
assert get_heatmap_columns(data, "A") == []
assert any(missing_metric in warning["message"] for warning in get_all_warnings(data))
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
@pytest.mark.parametrize(
"bucket_options, expected_buckets",
@@ -881,18 +938,19 @@ def test_negative_values(
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
queries = [build_builder_query("A", metric_name, "max", "max", bucket_options=bucket_options)]
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
[build_builder_query("A", metric_name, "max", "max", bucket_options=bucket_options)],
queries,
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)
assert get_buckets(data, "A") == pytest.approx(expected_buckets)
columns = get_heatmap_columns(data, "A")
assert [column["values"] for column in columns] == [
@@ -902,6 +960,8 @@ def test_negative_values(
[0, 0, 1, 0],
]
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
# a formula's output is bucketed after the query runs rather than by it, and
# has to put everything at or below zero where a metric query does: in the zero
@@ -943,21 +1003,22 @@ def test_formula_negative_values(
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
queries = [
build_builder_query("A", metric_name, "max", "max", disabled=True),
build_formula_query("F1", "A", bucket_options=bucket_options),
]
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
[
build_builder_query("A", metric_name, "max", "max", disabled=True),
build_formula_query("F1", "A", bucket_options=bucket_options),
],
queries,
request_type=RequestType.HEATMAP,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()
assert get_heatmap_buckets(data, "F1") == pytest.approx(expected_buckets)
assert get_buckets(data, "F1") == pytest.approx(expected_buckets)
columns = get_heatmap_columns(data, "F1")
assert [column["values"] for column in columns] == [
@@ -966,3 +1027,5 @@ def test_formula_negative_values(
[1, 0, 0, 0],
[0, 0, 1, 0],
]
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)

View File

@@ -24,6 +24,11 @@ MINUTE_MS = 60_000
METRIC_NAME = "test_heatmap_rejections"
QUERY = [build_builder_query("A", METRIC_NAME, "max", "max")]
request_types = pytest.mark.parametrize(
"request_type",
[RequestType.HEATMAP, RequestType.DISTRIBUTION],
ids=["heatmap", "distribution"],
)
@pytest.mark.parametrize(
@@ -46,7 +51,7 @@ QUERY = [build_builder_query("A", METRIC_NAME, "max", "max")]
pytest.param(
QUERY,
{"format_options": {"formatTableResultForUI": False, "fillGaps": True}},
"fillGaps is not supported for heatmap requests",
"fillGaps is not supported for {request_type} requests",
id="fill_gaps",
),
pytest.param(
@@ -55,7 +60,7 @@ QUERY = [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",
"{request_type} requests take one enabled query, but 2 are enabled",
id="two_enabled_queries",
),
pytest.param(
@@ -65,13 +70,13 @@ QUERY = [build_builder_query("A", METRIC_NAME, "max", "max")]
build_formula_query("F1", "B"),
],
{},
"a heatmap renders one distribution, but 2 queries are enabled",
"{request_type} requests take one enabled query, but 2 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",
"a {request_type} 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
@@ -85,7 +90,7 @@ QUERY = [build_builder_query("A", METRIC_NAME, "max", "max")]
pytest.param(
[build_builder_query("A", METRIC_NAME, "max", "max", functions=[build_function("absolute")])],
{},
"functions are not supported for heatmap requests",
"functions are not supported for {request_type} requests",
id="functions_on_the_query",
),
pytest.param(
@@ -94,7 +99,7 @@ QUERY = [build_builder_query("A", METRIC_NAME, "max", "max")]
build_formula_query("F1", "A"),
],
{},
"functions are not supported for heatmap requests",
"functions are not supported for {request_type} requests",
id="functions_on_a_disabled_formula_input",
),
pytest.param(
@@ -103,7 +108,7 @@ QUERY = [build_builder_query("A", METRIC_NAME, "max", "max")]
build_formula_query("F1", "A", functions=[build_function("absolute")]),
],
{},
"functions are not supported for heatmap requests",
"functions are not supported for {request_type} requests",
id="functions_on_the_formula",
),
pytest.param(
@@ -120,7 +125,7 @@ QUERY = [build_builder_query("A", METRIC_NAME, "max", "max")]
}
],
{},
"having is not supported for heatmap requests",
"having is not supported for {request_type} requests",
id="having_on_the_query",
),
pytest.param(
@@ -190,13 +195,15 @@ QUERY = [build_builder_query("A", METRIC_NAME, "max", "max")]
),
],
)
def test_heatmap_request_is_rejected(
@request_types
def test_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,
request_type: str,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
@@ -207,11 +214,11 @@ def test_heatmap_request_is_rejected(
int((now - timedelta(minutes=30)).timestamp() * 1000),
int(now.timestamp() * 1000),
queries,
request_type=RequestType.HEATMAP,
request_type=request_type,
**request_options,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert expected_message in get_error_message(response.json())
assert expected_message.format(request_type=request_type) in get_error_message(response.json()), get_error_message(response.json())
@pytest.mark.parametrize(
@@ -237,7 +244,7 @@ def test_bucket_options_outside_a_heatmap(
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())
assert "bucketOptions are only supported for heatmap and distribution requests" in get_error_message(response.json()), get_error_message(response.json())
@pytest.mark.parametrize(
@@ -256,11 +263,13 @@ def test_bucket_options_outside_a_heatmap(
),
],
)
@request_types
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],
request_type: str,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
start = now - timedelta(minutes=2)
@@ -282,16 +291,18 @@ def test_clickhouse_missing_bucket_columns_are_rejected(
},
}
],
request_type=RequestType.HEATMAP,
request_type=request_type,
)
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())
assert 'a heatmap needs a "__bucket_min" and a "__bucket_max" column' in get_error_message(response.json()), get_error_message(response.json())
@request_types
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],
request_type: str,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
start = now - timedelta(minutes=2)
@@ -313,7 +324,7 @@ def test_clickhouse_one_bucket_cut_two_ways_is_rejected(
},
}
],
request_type=RequestType.HEATMAP,
request_type=request_type,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
@@ -326,10 +337,12 @@ def test_clickhouse_one_bucket_cut_two_ways_is_rejected(
)
@request_types
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],
request_type: str,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
start = now - timedelta(minutes=2)
@@ -351,7 +364,7 @@ def test_clickhouse_one_bucket_cut_two_ways_across_groups_is_rejected(
},
}
],
request_type=RequestType.HEATMAP,
request_type=request_type,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
@@ -364,11 +377,13 @@ def test_clickhouse_one_bucket_cut_two_ways_across_groups_is_rejected(
)
@request_types
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],
request_type: str,
) -> 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
@@ -397,17 +412,19 @@ def test_promql_returning_no_le_is_rejected(
start_ms,
end_ms,
[{"type": "promql", "spec": {"name": "A", "query": metric, "step": 60}}],
request_type=RequestType.HEATMAP,
request_type=request_type,
)
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())
assert "no `le` labels to build a bucket axis from" in get_error_message(response.json()), get_error_message(response.json())
@request_types
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],
request_type: str,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
@@ -429,7 +446,7 @@ def test_histogram_rejects_bucket_options(
start_ms,
end_ms,
[build_builder_query("A", metric_name, "increase", "p50", bucket_options=build_log_bucket_options(2))],
request_type=RequestType.HEATMAP,
request_type=request_type,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert "bucketOptions are not supported for histogram metrics" in get_error_message(response.json())
assert "bucketOptions are not supported for histogram metrics" in get_error_message(response.json()), get_error_message(response.json())

View File

@@ -10,10 +10,11 @@ from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.querier import (
RequestType,
assert_distribution_sums_heatmap,
assert_identical_query_response,
build_builder_query,
build_linear_bucket_options,
get_heatmap_buckets,
get_buckets,
get_heatmap_columns,
make_query_request,
)
@@ -65,7 +66,7 @@ def test_builder_narrowing_to_half_the_range(
# the whole range first, which is what puts its axis in the cache
whole_range = make_query_request(signoz, token, start_time_ms, end_time_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
assert whole_range.status_code == HTTPStatus.OK, whole_range.text
assert get_heatmap_buckets(whole_range.json(), "A") == pytest.approx([100, 200, 300, 400, 500, 600, 700, 800, 900])
assert get_buckets(whole_range.json(), "A") == pytest.approx([100, 200, 300, 400, 500, 600, 700, 800, 900])
assert [column["values"] for column in get_heatmap_columns(whole_range.json(), "A")] == [
[0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
@@ -78,6 +79,7 @@ def test_builder_narrowing_to_half_the_range(
[0, 0, 0, 0, 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 0],
]
assert_distribution_sums_heatmap(signoz, token, start_time_ms, end_time_ms, query, whole_range, no_cache=False)
half_start_ms = start_time_ms + first_minute * MINUTE_MS
half_end_ms = half_start_ms + 5 * MINUTE_MS
@@ -88,7 +90,7 @@ def test_builder_narrowing_to_half_the_range(
assert uncached.status_code == HTTPStatus.OK, uncached.text
for source, response in (("uncached", uncached), ("from cache", from_cache)):
assert get_heatmap_buckets(response.json(), "A") == pytest.approx(expected_buckets), source
assert get_buckets(response.json(), "A") == pytest.approx(expected_buckets), source
assert [column["values"] for column in get_heatmap_columns(response.json(), "A")] == [
[0, 1, 0],
[0, 1, 0],
@@ -98,6 +100,7 @@ def test_builder_narrowing_to_half_the_range(
], source
assert_identical_query_response(from_cache, uncached)
assert_distribution_sums_heatmap(signoz, token, half_start_ms, half_end_ms, query, uncached, no_cache=False)
def test_builder_narrowing_a_histogram(
@@ -145,7 +148,7 @@ def test_builder_narrowing_a_histogram(
# the whole range first, which is what puts its axis in the cache
whole_range = make_query_request(signoz, token, start_time_ms, end_time_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
assert whole_range.status_code == HTTPStatus.OK, whole_range.text
assert get_heatmap_buckets(whole_range.json(), "A") == [1, 2, 4, 8]
assert get_buckets(whole_range.json(), "A") == [1, 2, 4, 8]
assert [column["values"] for column in get_heatmap_columns(whole_range.json(), "A")] == [
[10, 0, 0, 0, 0],
[10, 0, 0, 0, 0],
@@ -158,6 +161,7 @@ def test_builder_narrowing_a_histogram(
[0, 0, 0, 10, 0],
[0, 0, 0, 10, 0],
]
assert_distribution_sums_heatmap(signoz, token, start_time_ms, end_time_ms, query, whole_range, no_cache=False)
# even though this shortened time range has no data below 4, all histogram
# buckets are still returned back
@@ -169,7 +173,7 @@ def test_builder_narrowing_a_histogram(
assert uncached.status_code == HTTPStatus.OK, uncached.text
for source, response in (("uncached", uncached), ("from cache", from_cache)):
assert get_heatmap_buckets(response.json(), "A") == [1, 2, 4, 8], source
assert get_buckets(response.json(), "A") == [1, 2, 4, 8], source
assert [column["values"] for column in get_heatmap_columns(response.json(), "A")] == [
[0, 0, 0, 10, 0],
[0, 0, 0, 10, 0],
@@ -179,6 +183,7 @@ def test_builder_narrowing_a_histogram(
], source
assert_identical_query_response(from_cache, uncached)
assert_distribution_sums_heatmap(signoz, token, half_start_ms, end_time_ms, query, uncached, no_cache=False)
def test_builder_shortening_the_time_range_at_the_end(
@@ -222,13 +227,14 @@ def test_builder_shortening_the_time_range_at_the_end(
# 100 wide buckets, and the two maxes are 150 and 850, so the axis runs from
# the bottom of (100, 200] to the top of (800, 900]
assert get_heatmap_buckets(base_query.json(), "A") == pytest.approx([100, 200, 300, 400, 500, 600, 700, 800, 900])
assert get_buckets(base_query.json(), "A") == pytest.approx([100, 200, 300, 400, 500, 600, 700, 800, 900])
base_columns = get_heatmap_columns(base_query.json(), "A")
assert [column["values"] for column in base_columns] == [
[0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 0],
]
assert [column.get("partial", False) for column in base_columns] == [False, False]
assert_distribution_sums_heatmap(signoz, token, start_time_ms, end_time_ms_base_query, query, base_query, no_cache=False)
from_cache = make_query_request(signoz, token, start_time_ms, end_time_ms_shortened_query, query, request_type=RequestType.HEATMAP, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
@@ -240,7 +246,7 @@ def test_builder_shortening_the_time_range_at_the_end(
# is 250 and which comes back partial. Nothing in this window passes 300, so
# the axis stops there rather than carrying the buckets above it
for label, response in (("uncached", uncached), ("from cache", from_cache)):
assert get_heatmap_buckets(response.json(), "A") == pytest.approx([100, 200, 300]), label
assert get_buckets(response.json(), "A") == pytest.approx([100, 200, 300]), label
columns = get_heatmap_columns(response.json(), "A")
assert [column["values"] for column in columns] == [
[0, 1, 0, 0],
@@ -249,6 +255,7 @@ def test_builder_shortening_the_time_range_at_the_end(
assert [column.get("partial", False) for column in columns] == [False, True], label
assert_identical_query_response(from_cache, uncached)
assert_distribution_sums_heatmap(signoz, token, start_time_ms, end_time_ms_shortened_query, query, uncached, no_cache=False)
def test_builder_shortening_the_time_range_at_the_start(
@@ -294,13 +301,14 @@ def test_builder_shortening_the_time_range_at_the_start(
# 100 wide buckets, and the two maxes are 950 and 350, so the axis runs from
# the bottom of (300, 400] to the top of (900, 1000]
assert get_heatmap_buckets(base_query.json(), "A") == pytest.approx([300, 400, 500, 600, 700, 800, 900, 1000])
assert get_buckets(base_query.json(), "A") == pytest.approx([300, 400, 500, 600, 700, 800, 900, 1000])
base_columns = get_heatmap_columns(base_query.json(), "A")
assert [column["values"] for column in base_columns] == [
[0, 0, 0, 0, 0, 0, 0, 1, 0],
[0, 1, 0, 0, 0, 0, 0, 0, 0],
]
assert [column.get("partial", False) for column in base_columns] == [False, False]
assert_distribution_sums_heatmap(signoz, token, start_time_ms_base_query, end_time_ms, query, base_query, no_cache=False)
from_cache = make_query_request(signoz, token, start_time_ms_shortened_query, end_time_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
@@ -312,7 +320,7 @@ def test_builder_shortening_the_time_range_at_the_start(
# clipping its counts, which still cover the whole step and so reach the 950
# at minute 0, leaving the axis where the base query drew it
for label, response in (("uncached", uncached), ("from cache", from_cache)):
assert get_heatmap_buckets(response.json(), "A") == pytest.approx([300, 400, 500, 600, 700, 800, 900, 1000]), label
assert get_buckets(response.json(), "A") == pytest.approx([300, 400, 500, 600, 700, 800, 900, 1000]), label
columns = get_heatmap_columns(response.json(), "A")
assert [column["values"] for column in columns] == [
[0, 0, 0, 0, 0, 0, 0, 1, 0],
@@ -321,6 +329,7 @@ def test_builder_shortening_the_time_range_at_the_start(
assert [column.get("partial", False) for column in columns] == [True, False], label
assert_identical_query_response(from_cache, uncached)
assert_distribution_sums_heatmap(signoz, token, start_time_ms_shortened_query, end_time_ms, query, uncached, no_cache=False)
def test_builder_refreshing_a_sliding_time_range(
@@ -382,7 +391,7 @@ def test_builder_refreshing_a_sliding_time_range(
from_cache = make_query_request(signoz, token, refresh_start_ms, refresh_start_ms + 4 * MINUTE_MS, query, request_type=RequestType.HEATMAP, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
assert get_heatmap_buckets(from_cache.json(), "A") == pytest.approx(expected_buckets), f"refresh {refresh}"
assert get_buckets(from_cache.json(), "A") == pytest.approx(expected_buckets), f"refresh {refresh}"
# a column served twice, dropped, or carried over from an earlier refresh
# breaks the diagonal or the run of timestamps
@@ -394,6 +403,7 @@ def test_builder_refreshing_a_sliding_time_range(
refresh_start_ms + 3 * MINUTE_MS,
], f"refresh {refresh}"
assert [column["values"] for column in columns] == expected_columns, f"refresh {refresh}"
assert_distribution_sums_heatmap(signoz, token, refresh_start_ms, refresh_start_ms + 4 * MINUTE_MS, query, from_cache, no_cache=False)
uncached = make_query_request(signoz, token, refresh_start_ms, refresh_start_ms + 4 * MINUTE_MS, query, request_type=RequestType.HEATMAP, no_cache=True)
assert uncached.status_code == HTTPStatus.OK, uncached.text
@@ -453,7 +463,7 @@ def test_promql_running_the_same_query_twice(
# promql reports a column at the instant the range closes, and the second
# run, answered out of what the first one cached, has to keep it
for run, response in (("first", first), ("second", second)):
assert get_heatmap_buckets(response.json(), "A") == [1, 2, 4], run
assert get_buckets(response.json(), "A") == [1, 2, 4], run
## what the query returns per `le` is cumulative across `le`, so each
## count 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
@@ -465,6 +475,10 @@ def test_promql_running_the_same_query_twice(
], f"{run} run"
assert_identical_query_response(first, second)
# a distribution answered out of what its own first run cached keeps that
# closing column too
for _ in ("first", "second"):
assert_distribution_sums_heatmap(signoz, token, start_time_ms, end_time_ms, query, second, no_cache=False)
def test_promql_shifting_the_time_range(
@@ -510,7 +524,7 @@ def test_promql_shifting_the_time_range(
aligned_and_cached = make_query_request(signoz, token, aligned_start_time_ms, aligned_end_time_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
assert aligned_and_cached.status_code == HTTPStatus.OK, aligned_and_cached.text
assert get_heatmap_buckets(aligned_and_cached.json(), "A") == [1, 2, 4]
assert get_buckets(aligned_and_cached.json(), "A") == [1, 2, 4]
## each column reads the counters at their latest sample at or before its
## timestamp, and a bucket holds its own `le`'s count less the one below it.
aligned_columns = [
@@ -520,14 +534,16 @@ def test_promql_shifting_the_time_range(
(aligned_end_time_ms, [1000, 2000, 3000, 4000]), # t = 3m, the tenth
]
assert [(column["timestamp"], column["values"]) for column in get_heatmap_columns(aligned_and_cached.json(), "A")] == aligned_columns
assert_distribution_sums_heatmap(signoz, token, aligned_start_time_ms, aligned_end_time_ms, query, aligned_and_cached, no_cache=False)
# an unaligned range is rounded down to the step, so this asks for the same
# instants as the aligned query and gets the same answer
unaligned_and_cached = make_query_request(signoz, token, unaligned_start_time_ms, unaligned_end_time_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
assert unaligned_and_cached.status_code == HTTPStatus.OK, unaligned_and_cached.text
assert get_heatmap_buckets(unaligned_and_cached.json(), "A") == [1, 2, 4], "unaligned query, rounded to the step"
assert get_buckets(unaligned_and_cached.json(), "A") == [1, 2, 4], "unaligned query, rounded to the step"
assert [(column["timestamp"], column["values"]) for column in get_heatmap_columns(unaligned_and_cached.json(), "A")] == aligned_columns, "unaligned query, rounded to the step"
assert_identical_query_response(unaligned_and_cached, aligned_and_cached)
assert_distribution_sums_heatmap(signoz, token, unaligned_start_time_ms, unaligned_end_time_ms, query, unaligned_and_cached, no_cache=False)
# skipping the alignment keeps the range as asked, so every column sits 30s
# off the cached ones, on samples the aligned query never saw. Such a query
@@ -542,5 +558,6 @@ def test_promql_shifting_the_time_range(
for run in ("first", "second"):
unaligned_as_asked = make_query_request(signoz, token, unaligned_start_time_ms, unaligned_end_time_ms, unaligned_query, request_type=RequestType.HEATMAP, no_cache=False)
assert unaligned_as_asked.status_code == HTTPStatus.OK, unaligned_as_asked.text
assert get_heatmap_buckets(unaligned_as_asked.json(), "A") == [1, 2, 4], f"unaligned query as asked, {run} run"
assert get_buckets(unaligned_as_asked.json(), "A") == [1, 2, 4], f"unaligned query as asked, {run} run"
assert [(column["timestamp"], column["values"]) for column in get_heatmap_columns(unaligned_as_asked.json(), "A")] == unaligned_columns, f"unaligned query as asked, {run} run"
assert_distribution_sums_heatmap(signoz, token, unaligned_start_time_ms, unaligned_end_time_ms, unaligned_query, unaligned_as_asked, no_cache=False)