queryservice.go 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. package cloudcost
  2. import (
  3. "fmt"
  4. "net/http"
  5. "strings"
  6. "github.com/julienschmidt/httprouter"
  7. "github.com/opencost/opencost/core/pkg/log"
  8. "github.com/opencost/opencost/core/pkg/opencost"
  9. "github.com/opencost/opencost/core/pkg/util/httputil"
  10. "github.com/opencost/opencost/pkg/currency"
  11. "go.opentelemetry.io/otel"
  12. )
  13. const tracerName = "github.com/opencost/opencost/pkg/cloudcost"
  14. const (
  15. csvFormat = "csv"
  16. )
  17. // QueryService surfaces endpoints for accessing CloudCost data in raw form or for display in views
  18. type QueryService struct {
  19. Querier Querier
  20. ViewQuerier ViewQuerier
  21. CurrencyConverter currency.Converter
  22. }
  23. func NewQueryService(querier Querier, viewQuerier ViewQuerier) *QueryService {
  24. return &QueryService{
  25. Querier: querier,
  26. ViewQuerier: viewQuerier,
  27. }
  28. }
  29. func (s *QueryService) GetCloudCostHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  30. // Return valid handler func
  31. return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  32. tracer := otel.Tracer(tracerName)
  33. ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostHandler")
  34. defer span.End()
  35. // If Query Service is nil, always return 501
  36. if s == nil {
  37. http.Error(w, "Query Service is nil", http.StatusNotImplemented)
  38. return
  39. }
  40. if s.Querier == nil {
  41. http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
  42. return
  43. }
  44. qp := httputil.NewQueryParams(r.URL.Query())
  45. request, err := ParseCloudCostRequest(qp)
  46. if err != nil {
  47. http.Error(w, err.Error(), http.StatusBadRequest)
  48. return
  49. }
  50. resp, err := s.Querier.Query(ctx, *request)
  51. if err != nil {
  52. http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
  53. return
  54. }
  55. // Extract currency parameter and convert if needed
  56. currencyParam := strings.ToUpper(strings.TrimSpace(qp.Get("currency", "USD")))
  57. if currencyParam != "USD" && s.CurrencyConverter != nil && resp != nil {
  58. err = convertCloudCostSetRange(resp, s.CurrencyConverter, currencyParam)
  59. if err != nil {
  60. log.Warnf("Currency conversion failed for currency %s: %v", currencyParam, err)
  61. // Continue with USD values if conversion fails
  62. }
  63. }
  64. _, spanResp := tracer.Start(ctx, "write response")
  65. w.Header().Set("Content-Type", "application/json")
  66. protocol.WriteData(w, resp)
  67. spanResp.End()
  68. }
  69. }
  70. func (s *QueryService) GetCloudCostViewGraphHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  71. // Return valid handler func
  72. return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  73. tracer := otel.Tracer(tracerName)
  74. ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostViewGraphHandler")
  75. defer span.End()
  76. // If Query Service is nil, always return 501
  77. if s == nil {
  78. http.Error(w, "Query Service is nil", http.StatusNotImplemented)
  79. return
  80. }
  81. if s.ViewQuerier == nil {
  82. http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
  83. return
  84. }
  85. qp := httputil.NewQueryParams(r.URL.Query())
  86. request, err := ParseCloudCostViewRequest(qp)
  87. if err != nil {
  88. http.Error(w, err.Error(), http.StatusBadRequest)
  89. return
  90. }
  91. resp, err := s.ViewQuerier.QueryViewGraph(ctx, *request)
  92. if err != nil {
  93. http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
  94. return
  95. }
  96. _, spanResp := tracer.Start(ctx, "write response")
  97. w.Header().Set("Content-Type", "application/json")
  98. protocol.WriteData(w, resp)
  99. spanResp.End()
  100. }
  101. }
  102. func (s *QueryService) GetCloudCostViewTotalsHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  103. // Return valid handler func
  104. return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  105. tracer := otel.Tracer(tracerName)
  106. ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostViewTotalsHandler")
  107. defer span.End()
  108. // If Query Service is nil, always return 501
  109. if s == nil {
  110. http.Error(w, "Query Service is nil", http.StatusNotImplemented)
  111. return
  112. }
  113. if s.ViewQuerier == nil {
  114. http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
  115. return
  116. }
  117. qp := httputil.NewQueryParams(r.URL.Query())
  118. request, err := ParseCloudCostViewRequest(qp)
  119. if err != nil {
  120. http.Error(w, err.Error(), http.StatusBadRequest)
  121. return
  122. }
  123. resp, err := s.ViewQuerier.QueryViewTotals(ctx, *request)
  124. if err != nil {
  125. http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
  126. return
  127. }
  128. _, spanResp := tracer.Start(ctx, "write response")
  129. w.Header().Set("Content-Type", "application/json")
  130. protocol.WriteData(w, resp)
  131. spanResp.End()
  132. }
  133. }
  134. func (s *QueryService) GetCloudCostViewTableHandler(tokenHook func(ViewTableRows) string) func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  135. // Return valid handler func
  136. return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  137. tracer := otel.Tracer(tracerName)
  138. ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostViewTableHandler")
  139. defer span.End()
  140. // If Query Service is nil, always return 501
  141. if s == nil {
  142. http.Error(w, "Query Service is nil", http.StatusNotImplemented)
  143. return
  144. }
  145. if s.ViewQuerier == nil {
  146. http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
  147. return
  148. }
  149. qp := httputil.NewQueryParams(r.URL.Query())
  150. request, err := ParseCloudCostViewRequest(qp)
  151. if err != nil {
  152. http.Error(w, err.Error(), http.StatusBadRequest)
  153. return
  154. }
  155. format := qp.Get("format", "json")
  156. if strings.HasPrefix(format, csvFormat) {
  157. w.Header().Set("Content-Type", "text/csv")
  158. w.Header().Set("Transfer-Encoding", "chunked")
  159. } else {
  160. // By default, send JSON
  161. w.Header().Set("Content-Type", "application/json")
  162. }
  163. rows, err := s.ViewQuerier.QueryViewTable(ctx, *request)
  164. if err != nil {
  165. http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
  166. return
  167. }
  168. resp := protocol.NewResponse().WithData(rows)
  169. if tokenHook != nil {
  170. resp = resp.WithMeta(map[string]any{
  171. "token": tokenHook(rows),
  172. })
  173. }
  174. _, spanResp := tracer.Start(ctx, "write response")
  175. defer spanResp.End()
  176. if format == csvFormat {
  177. window := opencost.NewClosedWindow(request.Start, request.End)
  178. writeCloudCostViewTableRowsAsCSV(w, rows, window.String())
  179. return
  180. }
  181. w.Header().Set("Content-Type", "application/json")
  182. protocol.WriteResponse(w, resp)
  183. }
  184. }
  185. // convertCloudCostSetRange converts all cloud costs in a range from USD
  186. // to target currency in place. Returns an error if the target rate
  187. // cannot be looked up upfront (no mutation occurs). Per-field converter
  188. // failures after the probe succeeded are handled best-effort: the field
  189. // is left in USD and a warning is logged. Only CostMetric.Cost is
  190. // mutated; KubernetesPercent and other non-cost fields are untouched.
  191. func convertCloudCostSetRange(ccsr *opencost.CloudCostSetRange, converter currency.Converter, targetCurrency string) error {
  192. if ccsr == nil || converter == nil {
  193. return nil
  194. }
  195. targetCurrency = strings.ToUpper(strings.TrimSpace(targetCurrency))
  196. if targetCurrency == "" || targetCurrency == "USD" {
  197. return nil
  198. }
  199. if _, err := converter.GetRate("USD", targetCurrency); err != nil {
  200. return fmt.Errorf("currency rate lookup USD->%s failed: %w", targetCurrency, err)
  201. }
  202. tryConvert := func(val float64, logCtx string) float64 {
  203. if val == 0 {
  204. return val
  205. }
  206. converted, err := converter.Convert(val, "USD", targetCurrency)
  207. if err != nil {
  208. log.Warnf("currency: leaving %s in USD (convert to %s failed): %v", logCtx, targetCurrency, err)
  209. return val
  210. }
  211. return converted
  212. }
  213. for _, set := range ccsr.CloudCostSets {
  214. if set == nil {
  215. continue
  216. }
  217. for _, cc := range set.CloudCosts {
  218. if cc == nil {
  219. continue
  220. }
  221. cc.ListCost.Cost = tryConvert(cc.ListCost.Cost, "CloudCost.ListCost")
  222. cc.NetCost.Cost = tryConvert(cc.NetCost.Cost, "CloudCost.NetCost")
  223. cc.AmortizedNetCost.Cost = tryConvert(cc.AmortizedNetCost.Cost, "CloudCost.AmortizedNetCost")
  224. cc.InvoicedCost.Cost = tryConvert(cc.InvoicedCost.Cost, "CloudCost.InvoicedCost")
  225. cc.AmortizedCost.Cost = tryConvert(cc.AmortizedCost.Cost, "CloudCost.AmortizedCost")
  226. }
  227. }
  228. return nil
  229. }