queryservice.go 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. package customcost
  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/util/httputil"
  9. "github.com/opencost/opencost/pkg/currency"
  10. "go.opentelemetry.io/otel"
  11. )
  12. const tracerName = "github.com/opencost/opencost/pkg/customcost"
  13. type QueryService struct {
  14. Querier Querier
  15. CurrencyConverter currency.Converter
  16. }
  17. func NewQueryService(querier Querier) *QueryService {
  18. return &QueryService{
  19. Querier: querier,
  20. }
  21. }
  22. func (qs *QueryService) GetCustomCostTotalHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  23. return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  24. tracer := otel.Tracer(tracerName)
  25. ctx, span := tracer.Start(r.Context(), "Service.GetCustomCostTotalHandler")
  26. defer span.End()
  27. // If Query Service is nil, always return 501
  28. if qs == nil {
  29. http.Error(w, "Query Service is nil", http.StatusNotImplemented)
  30. return
  31. }
  32. if qs.Querier == nil {
  33. http.Error(w, "CustomCost Query Service is nil", http.StatusNotImplemented)
  34. return
  35. }
  36. qp := httputil.NewQueryParams(r.URL.Query())
  37. request, err := ParseCustomCostTotalRequest(qp)
  38. if err != nil {
  39. http.Error(w, err.Error(), http.StatusBadRequest)
  40. return
  41. }
  42. resp, err := qs.Querier.QueryTotal(ctx, *request)
  43. if err != nil {
  44. http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
  45. return
  46. }
  47. // Extract currency parameter and convert if needed
  48. currencyParam := strings.ToUpper(strings.TrimSpace(qp.Get("currency", "USD")))
  49. if currencyParam != "USD" && qs.CurrencyConverter != nil && resp != nil {
  50. err = convertCustomCostResponse(resp, qs.CurrencyConverter, currencyParam)
  51. if err != nil {
  52. log.Warnf("Currency conversion failed for currency %s: %v", currencyParam, err)
  53. // Continue with USD values if conversion fails
  54. }
  55. }
  56. _, spanResp := tracer.Start(ctx, "write response")
  57. w.Header().Set("Content-Type", "application/json")
  58. protocol.WriteData(w, resp)
  59. spanResp.End()
  60. }
  61. }
  62. func (qs *QueryService) GetCustomCostTimeseriesHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  63. return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
  64. tracer := otel.Tracer(tracerName)
  65. ctx, span := tracer.Start(r.Context(), "Service.GetCustomCostTimeseriesHandler")
  66. defer span.End()
  67. // If Query Service is nil, always return 501
  68. if qs == nil {
  69. http.Error(w, "Query Service is nil", http.StatusNotImplemented)
  70. return
  71. }
  72. if qs.Querier == nil {
  73. http.Error(w, "CustomCost Query Service is nil", http.StatusNotImplemented)
  74. return
  75. }
  76. qp := httputil.NewQueryParams(r.URL.Query())
  77. request, err := ParseCustomCostTimeseriesRequest(qp)
  78. if err != nil {
  79. http.Error(w, err.Error(), http.StatusBadRequest)
  80. return
  81. }
  82. resp, err := qs.Querier.QueryTimeseries(ctx, *request)
  83. if err != nil {
  84. http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
  85. return
  86. }
  87. // Extract currency parameter and convert if needed
  88. currencyParam := strings.ToUpper(strings.TrimSpace(qp.Get("currency", "USD")))
  89. if currencyParam != "USD" && qs.CurrencyConverter != nil && resp != nil {
  90. err = convertCustomCostTimeseriesResponse(resp, qs.CurrencyConverter, currencyParam)
  91. if err != nil {
  92. log.Warnf("Currency conversion failed for currency %s: %v", currencyParam, err)
  93. // Continue with USD values if conversion fails
  94. }
  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. // convertCustomCostResponse converts all cost values in a CostResponse
  103. // from USD to target currency in place. Returns an error if the target
  104. // rate cannot be looked up upfront (no mutation occurs). Per-field
  105. // converter failures after the probe succeeded are handled best-effort:
  106. // the field is left in USD and a warning is logged.
  107. //
  108. // CustomCost uses float32; conversion is done in float64 to avoid
  109. // compounding precision loss, then cast back.
  110. func convertCustomCostResponse(resp *CostResponse, converter currency.Converter, targetCurrency string) error {
  111. if resp == nil || converter == nil {
  112. return nil
  113. }
  114. targetCurrency = strings.ToUpper(strings.TrimSpace(targetCurrency))
  115. if targetCurrency == "" || targetCurrency == "USD" {
  116. return nil
  117. }
  118. if _, err := converter.GetRate("USD", targetCurrency); err != nil {
  119. return fmt.Errorf("currency rate lookup USD->%s failed: %w", targetCurrency, err)
  120. }
  121. convertResponseFields(resp, converter, targetCurrency)
  122. return nil
  123. }
  124. // convertResponseFields applies the per-field conversion without
  125. // re-probing the rate. The caller must have already validated the
  126. // target currency.
  127. func convertResponseFields(resp *CostResponse, converter currency.Converter, targetCurrency string) {
  128. tryConvert32 := func(val float32, logCtx string) float32 {
  129. if val == 0 {
  130. return val
  131. }
  132. converted, err := converter.Convert(float64(val), "USD", targetCurrency)
  133. if err != nil {
  134. log.Warnf("currency: leaving %s in USD (convert to %s failed): %v", logCtx, targetCurrency, err)
  135. return val
  136. }
  137. return float32(converted)
  138. }
  139. resp.TotalCost = tryConvert32(resp.TotalCost, "CostResponse.TotalCost")
  140. for _, cc := range resp.CustomCosts {
  141. if cc == nil {
  142. continue
  143. }
  144. cc.Cost = tryConvert32(cc.Cost, "CustomCost.Cost")
  145. cc.ListUnitPrice = tryConvert32(cc.ListUnitPrice, "CustomCost.ListUnitPrice")
  146. }
  147. }
  148. // convertCustomCostTimeseriesResponse converts all cost values in a
  149. // CostTimeseriesResponse. Returns an error if the target rate cannot be
  150. // looked up upfront (no mutation occurs). Per-field conversion is
  151. // best-effort thereafter.
  152. func convertCustomCostTimeseriesResponse(resp *CostTimeseriesResponse, converter currency.Converter, targetCurrency string) error {
  153. if resp == nil || converter == nil {
  154. return nil
  155. }
  156. targetCurrency = strings.ToUpper(strings.TrimSpace(targetCurrency))
  157. if targetCurrency == "" || targetCurrency == "USD" {
  158. return nil
  159. }
  160. if _, err := converter.GetRate("USD", targetCurrency); err != nil {
  161. return fmt.Errorf("currency rate lookup USD->%s failed: %w", targetCurrency, err)
  162. }
  163. for _, costResp := range resp.Timeseries {
  164. if costResp == nil {
  165. continue
  166. }
  167. convertResponseFields(costResp, converter, targetCurrency)
  168. }
  169. return nil
  170. }