@@ -966,3 +966,167 @@ def test_metrics_fill_zero_formula_with_group_by(
966966 expected_by_ts = expectations [group ],
967967 context = f"metrics/fillZero/F1/{ group } " ,
968968 )
969+
970+
971+
972+ def test_metrics_heatmap (
973+ signoz : types .SigNoz ,
974+ create_user_admin : None , # pylint: disable=unused-argument
975+ get_token : Callable [[str , str ], str ],
976+ insert_metrics : Callable [[List [Metrics ]], None ],
977+ ) -> None :
978+ """
979+ Test heatmap query with metrics.
980+ """
981+ now = datetime .now (tz = timezone .utc ).replace (second = 0 , microsecond = 0 )
982+ metric_name = "test_heatmap_multi_bucket"
983+
984+ metrics : List [Metrics ] = []
985+
986+ # t-3: First histogram
987+ # Distribution: 10 in 0-10, 10 in 10-50, 10 in 50-100, 20 in 100+
988+ for le , value in [("10" , 10.0 ), ("50" , 20.0 ), ("100" , 30.0 ), ("+Inf" , 50.0 )]:
989+ metrics .append (
990+ Metrics (
991+ metric_name = metric_name ,
992+ labels = {"service" : "test" , "le" : le },
993+ timestamp = now - timedelta (minutes = 3 ),
994+ value = value ,
995+ temporality = "Cumulative" ,
996+ type_ = "Histogram" ,
997+ )
998+ )
999+
1000+ # t-2: Second histogram
1001+ # Total: 30 in 0-10, 40 in 10-50, 50 in 50-100, 90 in 100+
1002+ for le , value in [("10" , 30.0 ), ("50" , 60.0 ), ("100" , 90.0 ), ("+Inf" , 150.0 )]:
1003+ metrics .append (
1004+ Metrics (
1005+ metric_name = metric_name ,
1006+ labels = {"service" : "test" , "le" : le },
1007+ timestamp = now - timedelta (minutes = 2 ),
1008+ value = value ,
1009+ temporality = "Cumulative" ,
1010+ type_ = "Histogram" ,
1011+ )
1012+ )
1013+
1014+ # t-1: Third histogram
1015+ # Total: 40 in 0-10, 70 in 10-50, 100 in 50-100, 170 in 100+
1016+ for le , value in [("10" , 40.0 ), ("50" , 80.0 ), ("100" , 120.0 ), ("+Inf" , 200.0 )]:
1017+ metrics .append (
1018+ Metrics (
1019+ metric_name = metric_name ,
1020+ labels = {"service" : "test" , "le" : le },
1021+ timestamp = now - timedelta (minutes = 1 ),
1022+ value = value ,
1023+ temporality = "Cumulative" ,
1024+ type_ = "Histogram" ,
1025+ )
1026+ )
1027+
1028+ insert_metrics (metrics )
1029+
1030+ token = get_token (USER_ADMIN_EMAIL , USER_ADMIN_PASSWORD )
1031+
1032+ start_ms = int ((now - timedelta (minutes = 4 )).timestamp () * 1000 )
1033+ end_ms = int (now .timestamp () * 1000 )
1034+
1035+ response = requests .post (
1036+ signoz .self .host_configs ["8080" ].get ("/api/v5/query_range" ),
1037+ timeout = 5 ,
1038+ headers = {"authorization" : f"Bearer { token } " },
1039+ json = {
1040+ "schemaVersion" : "v1" ,
1041+ "start" : start_ms ,
1042+ "end" : end_ms ,
1043+ "requestType" : "heatmap" ,
1044+ "compositeQuery" : {
1045+ "queries" : [
1046+ {
1047+ "type" : "builder_query" ,
1048+ "spec" : {
1049+ "name" : "A" ,
1050+ "signal" : "metrics" ,
1051+ "aggregations" : [
1052+ {
1053+ "metricName" : metric_name ,
1054+ "temporality" : "cumulative" ,
1055+ "timeAggregation" : "increase" ,
1056+ "spaceAggregation" : "sum" ,
1057+ }
1058+ ],
1059+ "stepInterval" : 60 ,
1060+ "disabled" : False ,
1061+ },
1062+ }
1063+ ]
1064+ },
1065+ "formatOptions" : {"formatTableResultForUI" : False },
1066+ },
1067+ )
1068+
1069+ assert response .status_code == HTTPStatus .OK , f"Expected 200, got { response .status_code } : { response .text } "
1070+
1071+ response_data = response .json ()
1072+ assert response_data ["status" ] == "success" , f"Query failed: { response_data } "
1073+
1074+ results = response_data ["data" ]["data" ]["results" ]
1075+ assert len (results ) == 1 , f"Expected 1 result, got { len (results )} "
1076+
1077+ aggregations = results [0 ]["aggregations" ]
1078+ assert len (aggregations ) == 1 , f"Expected 1 aggregation, got { len (aggregations )} "
1079+
1080+ series = aggregations [0 ]["series" ]
1081+ # Heatmap returns one series with time points
1082+ assert len (series ) == 1 , f"Expected 1 series for heatmap, got { len (series )} : { series } "
1083+
1084+ # Verify the series has proper structure
1085+ s = series [0 ]
1086+ assert "values" in s , f"Series missing 'values': { s } "
1087+
1088+ values = s ["values" ]
1089+ assert isinstance (values , list ), f"Values should be a list, got { type (values )} "
1090+
1091+ # Should have exactly 3 time points (t-3, t-2, t-1)
1092+ assert len (values ) == 3
1093+
1094+ # Verify structure and basic invariants
1095+ for val in values :
1096+ assert isinstance (val , dict )
1097+ assert "timestamp" in val
1098+ assert "bucket" in val
1099+ assert "values" in val
1100+
1101+ bucket = val ["bucket" ]
1102+ assert "bounds" in bucket
1103+ bounds = bucket ["bounds" ]
1104+ assert isinstance (bounds , list )
1105+ assert len (bounds ) == 4 # 10, 50, 100, +Inf
1106+
1107+ counts = val ["values" ]
1108+ assert isinstance (counts , list )
1109+ assert len (counts ) == len (bounds ) - 1
1110+
1111+ for count in counts :
1112+ assert isinstance (count , (int , float ))
1113+ assert count >= 0
1114+
1115+ # Verify bucket bounds
1116+ assert values [0 ]["bucket" ]["bounds" ] == [0 , 10 , 50 , 100 ]
1117+
1118+ # Verify expected counts per timestamp
1119+ ts_min_3 = int ((now - timedelta (minutes = 3 )).timestamp () * 1000 )
1120+ ts_min_2 = int ((now - timedelta (minutes = 2 )).timestamp () * 1000 )
1121+ ts_min_1 = int ((now - timedelta (minutes = 1 )).timestamp () * 1000 )
1122+
1123+ expected_by_ts = {
1124+ ts_min_3 : [10.0 , 10.0 , 10.0 ],
1125+ ts_min_2 : [20.0 , 20.0 , 20.0 ],
1126+ ts_min_1 : [10.0 , 10.0 , 10.0 ],
1127+ }
1128+
1129+ for val in values :
1130+ if val ["timestamp" ] in expected_by_ts :
1131+ assert val ["values" ] == expected_by_ts [val ["timestamp" ]]
1132+
0 commit comments