datasource.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. package prom
  2. import (
  3. "context"
  4. "fmt"
  5. "net/http"
  6. "strconv"
  7. "time"
  8. "github.com/julienschmidt/httprouter"
  9. "github.com/opencost/opencost/modules/prometheus-source/pkg/env"
  10. "github.com/opencost/opencost/core/pkg/clusters"
  11. "github.com/opencost/opencost/core/pkg/log"
  12. "github.com/opencost/opencost/core/pkg/protocol"
  13. "github.com/opencost/opencost/core/pkg/source"
  14. "github.com/opencost/opencost/core/pkg/util/httputil"
  15. "github.com/opencost/opencost/core/pkg/util/json"
  16. prometheus "github.com/prometheus/client_golang/api"
  17. prometheusAPI "github.com/prometheus/client_golang/api/prometheus/v1"
  18. )
  19. const (
  20. apiPrefix = "/api/v1"
  21. epAlertManagers = apiPrefix + "/alertmanagers"
  22. epLabelValues = apiPrefix + "/label/:name/values"
  23. epSeries = apiPrefix + "/series"
  24. epTargets = apiPrefix + "/targets"
  25. epSnapshot = apiPrefix + "/admin/tsdb/snapshot"
  26. epDeleteSeries = apiPrefix + "/admin/tsdb/delete_series"
  27. epCleanTombstones = apiPrefix + "/admin/tsdb/clean_tombstones"
  28. epConfig = apiPrefix + "/status/config"
  29. epFlags = apiPrefix + "/status/flags"
  30. epRules = apiPrefix + "/rules"
  31. )
  32. // helper for query range proxy requests
  33. func toStartEndStep(qp httputil.QueryParams) (start, end time.Time, step time.Duration, err error) {
  34. var e error
  35. ss := qp.Get("start", "")
  36. es := qp.Get("end", "")
  37. ds := qp.Get("duration", "")
  38. layout := "2006-01-02T15:04:05.000Z"
  39. start, e = time.Parse(layout, ss)
  40. if e != nil {
  41. err = fmt.Errorf("Error parsing time %s. Error: %s", ss, err)
  42. return
  43. }
  44. end, e = time.Parse(layout, es)
  45. if e != nil {
  46. err = fmt.Errorf("Error parsing time %s. Error: %s", es, err)
  47. return
  48. }
  49. step, e = time.ParseDuration(ds)
  50. if e != nil {
  51. err = fmt.Errorf("Error parsing duration %s. Error: %s", ds, err)
  52. return
  53. }
  54. err = nil
  55. return
  56. }
  57. // creates a new help error which indicates the caller can retry and is non-fatal.
  58. func newHelpRetryError(format string, args ...any) error {
  59. formatWithHelp := format + "\nTroubleshooting help available at: %s"
  60. args = append(args, PrometheusTroubleshootingURL)
  61. cause := fmt.Errorf(formatWithHelp, args...)
  62. return source.NewHelpRetryError(cause)
  63. }
  64. // PrometheusDataSource is the OpenCost data source implementation leveraging Prometheus. Prometheus provides longer retention periods and
  65. // more detailed metrics than the OpenCost Collector, which is useful for historical analysis and cost forecasting.
  66. type PrometheusDataSource struct {
  67. promConfig *OpenCostPrometheusConfig
  68. promClient prometheus.Client
  69. promContexts *ContextFactory
  70. thanosConfig *OpenCostThanosConfig
  71. thanosClient prometheus.Client
  72. thanosContexts *ContextFactory
  73. metricsQuerier *PrometheusMetricsQuerier
  74. clusterMap clusters.ClusterMap
  75. clusterInfo clusters.ClusterInfoProvider
  76. }
  77. // NewDefaultPrometheusDataSource creates and initializes a new `PrometheusDataSource` with configuration
  78. // parsed from environment variables. This function will block until a connection to prometheus is established,
  79. // or fails. It is recommended to run this function in a goroutine on a retry cycle.
  80. func NewDefaultPrometheusDataSource(clusterInfoProvider clusters.ClusterInfoProvider) (*PrometheusDataSource, error) {
  81. config, err := NewOpenCostPrometheusConfigFromEnv()
  82. if err != nil {
  83. return nil, fmt.Errorf("failed to create prometheus config from env: %w", err)
  84. }
  85. var thanosConfig *OpenCostThanosConfig
  86. if env.IsThanosEnabled() {
  87. // thanos initialization is not fatal, so we log the error and continue
  88. thanosConfig, err = NewOpenCostThanosConfigFromEnv()
  89. if err != nil {
  90. log.Warnf("Thanos was enabled, but failed to create thanos config from env: %s. Continuing...", err.Error())
  91. }
  92. }
  93. return NewPrometheusDataSource(clusterInfoProvider, config, thanosConfig)
  94. }
  95. // NewPrometheusDataSource initializes clients for Prometheus and Thanos, and returns a new PrometheusDataSource.
  96. func NewPrometheusDataSource(infoProvider clusters.ClusterInfoProvider, promConfig *OpenCostPrometheusConfig, thanosConfig *OpenCostThanosConfig) (*PrometheusDataSource, error) {
  97. promClient, err := NewPrometheusClient(promConfig.ServerEndpoint, promConfig.ClientConfig)
  98. if err != nil {
  99. return nil, fmt.Errorf("failed to build prometheus client: %w", err)
  100. }
  101. // validation of the prometheus client
  102. m, err := Validate(promClient, promConfig)
  103. if err != nil || !m.Running {
  104. if err != nil {
  105. return nil, newHelpRetryError("failed to query prometheus at %s: %w", promConfig.ServerEndpoint, err)
  106. } else if !m.Running {
  107. return nil, newHelpRetryError("prometheus at %s is not running", promConfig.ServerEndpoint)
  108. }
  109. } else {
  110. log.Infof("Success: retrieved the 'up' query against prometheus at: %s", promConfig.ServerEndpoint)
  111. }
  112. // we don't consider this a fatal error, but we log for visibility
  113. api := prometheusAPI.NewAPI(promClient)
  114. _, err = api.Buildinfo(context.Background())
  115. if err != nil {
  116. log.Infof("No valid prometheus config file at %s. Error: %s.\nTroubleshooting help available at: %s.\n**Ignore if using cortex/mimir/thanos here**", promConfig.ServerEndpoint, err.Error(), PrometheusTroubleshootingURL)
  117. } else {
  118. log.Infof("Retrieved a prometheus config file from: %s", promConfig.ServerEndpoint)
  119. }
  120. // Fix scrape interval if zero by attempting to lookup the interval for the configured job
  121. if promConfig.ScrapeInterval == 0 {
  122. promConfig.ScrapeInterval = time.Minute
  123. // Lookup scrape interval for kubecost job, update if found
  124. si, err := ScrapeIntervalFor(promClient, promConfig.JobName)
  125. if err == nil {
  126. promConfig.ScrapeInterval = si
  127. }
  128. }
  129. log.Infof("Using scrape interval of %f", promConfig.ScrapeInterval.Seconds())
  130. promContexts := NewContextFactory(promClient, promConfig)
  131. var thanosClient prometheus.Client
  132. var thanosContexts *ContextFactory
  133. // if the thanos configuration is non-nil, we assume intent to use thanos. However, failure to
  134. // initialize the thanos client is not fatal, and we will log the error and continue.
  135. if thanosConfig != nil {
  136. thanosHost := thanosConfig.ServerEndpoint
  137. if thanosHost != "" {
  138. thanosCli, _ := NewThanosClient(thanosHost, thanosConfig)
  139. _, err = Validate(thanosCli, thanosConfig.OpenCostPrometheusConfig)
  140. if err != nil {
  141. log.Warnf("Failed to query Thanos at %s. Error: %s.", thanosHost, err.Error())
  142. thanosClient = thanosCli
  143. } else {
  144. log.Infof("Success: retrieved the 'up' query against Thanos at: %s", thanosHost)
  145. thanosClient = thanosCli
  146. }
  147. thanosContexts = NewContextFactory(thanosClient, thanosConfig.OpenCostPrometheusConfig)
  148. } else {
  149. log.Infof("Error resolving environment variable: $%s", env.ThanosQueryUrlEnvVar)
  150. }
  151. }
  152. // metadata creation for cluster info
  153. thanosEnabled := thanosClient != nil
  154. metadata := map[string]string{
  155. clusters.ClusterInfoThanosEnabledKey: fmt.Sprintf("%t", thanosEnabled),
  156. }
  157. if thanosEnabled {
  158. metadata[clusters.ClusterInfoThanosOffsetKey] = thanosConfig.Offset
  159. }
  160. // cluster info provider
  161. clusterInfoProvider := clusters.NewClusterInfoDecorator(infoProvider, metadata)
  162. var clusterMap clusters.ClusterMap
  163. if thanosEnabled {
  164. clusterMap = newPrometheusClusterMap(thanosContexts, clusterInfoProvider, 10*time.Minute)
  165. } else {
  166. clusterMap = newPrometheusClusterMap(promContexts, clusterInfoProvider, 5*time.Minute)
  167. }
  168. // create metrics querier implementation for prometheus and thanos
  169. metricsQuerier := newPrometheusMetricsQuerier(
  170. promConfig,
  171. promClient,
  172. promContexts,
  173. thanosConfig,
  174. thanosClient,
  175. thanosContexts,
  176. )
  177. return &PrometheusDataSource{
  178. promConfig: promConfig,
  179. promClient: promClient,
  180. promContexts: promContexts,
  181. thanosConfig: thanosConfig,
  182. thanosClient: thanosClient,
  183. thanosContexts: thanosContexts,
  184. metricsQuerier: metricsQuerier,
  185. clusterMap: clusterMap,
  186. clusterInfo: clusterInfoProvider,
  187. }, nil
  188. }
  189. var proto = protocol.HTTP()
  190. // prometheusMetadata returns the metadata for the prometheus server
  191. func (pds *PrometheusDataSource) prometheusMetadata(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
  192. w.Header().Set("Content-Type", "application/json")
  193. w.Header().Set("Access-Control-Allow-Origin", "*")
  194. resp := proto.ToResponse(Validate(pds.promClient, pds.promConfig))
  195. proto.WriteResponse(w, resp)
  196. }
  197. // prometheusRecordingRules is a proxy for /rules against prometheus
  198. func (pds *PrometheusDataSource) prometheusRecordingRules(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  199. w.Header().Set("Content-Type", "application/json")
  200. w.Header().Set("Access-Control-Allow-Origin", "*")
  201. u := pds.promClient.URL(epRules, nil)
  202. req, err := http.NewRequest(http.MethodGet, u.String(), nil)
  203. if err != nil {
  204. fmt.Fprintf(w, "error creating Prometheus rule request: %s", err)
  205. return
  206. }
  207. _, body, err := pds.promClient.Do(r.Context(), req)
  208. if err != nil {
  209. fmt.Fprintf(w, "error making Prometheus rule request: %s", err)
  210. return
  211. }
  212. w.Write(body)
  213. }
  214. // prometheusConfig returns the current configuration of the prometheus server
  215. func (pds *PrometheusDataSource) prometheusConfig(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  216. w.Header().Set("Content-Type", "application/json")
  217. w.Header().Set("Access-Control-Allow-Origin", "*")
  218. pConfig := map[string]string{
  219. "address": pds.promConfig.ServerEndpoint,
  220. }
  221. body, err := json.Marshal(pConfig)
  222. if err != nil {
  223. fmt.Fprintf(w, "Error marshalling prometheus config")
  224. } else {
  225. w.Write(body)
  226. }
  227. }
  228. // prometheusTargets is a proxy for /targets against prometheus
  229. func (pds *PrometheusDataSource) prometheusTargets(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  230. w.Header().Set("Content-Type", "application/json")
  231. w.Header().Set("Access-Control-Allow-Origin", "*")
  232. u := pds.promClient.URL(epTargets, nil)
  233. req, err := http.NewRequest(http.MethodGet, u.String(), nil)
  234. if err != nil {
  235. fmt.Fprintf(w, "error creating Prometheus rule request: %s", err)
  236. return
  237. }
  238. _, body, err := pds.promClient.Do(r.Context(), req)
  239. if err != nil {
  240. fmt.Fprintf(w, "error making Prometheus rule request: %s", err)
  241. return
  242. }
  243. w.Write(body)
  244. }
  245. // status returns the status of the prometheus client
  246. func (pds *PrometheusDataSource) status(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  247. w.Header().Set("Content-Type", "application/json")
  248. w.Header().Set("Access-Control-Allow-Origin", "*")
  249. promServer := pds.promConfig.ServerEndpoint
  250. api := prometheusAPI.NewAPI(pds.promClient)
  251. result, err := api.Buildinfo(r.Context())
  252. if err != nil {
  253. fmt.Fprintf(w, "Using Prometheus at %s, Error: %s", promServer, err)
  254. } else {
  255. fmt.Fprintf(w, "Using Prometheus at %s, version: %s", promServer, result.Version)
  256. }
  257. }
  258. // prometheusQuery is a proxy for /query against prometheus
  259. func (pds *PrometheusDataSource) prometheusQuery(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  260. w.Header().Set("Content-Type", "application/json")
  261. w.Header().Set("Access-Control-Allow-Origin", "*")
  262. qp := httputil.NewQueryParams(r.URL.Query())
  263. query := qp.Get("query", "")
  264. if query == "" {
  265. proto.WriteResponse(w, proto.ToResponse(nil, fmt.Errorf("Query Parameter 'query' is unset'")))
  266. return
  267. }
  268. // Attempt to parse time as either a unix timestamp or as an RFC3339 value
  269. var timeVal time.Time
  270. timeStr := qp.Get("time", "")
  271. if len(timeStr) > 0 {
  272. if t, err := strconv.ParseInt(timeStr, 10, 64); err == nil {
  273. timeVal = time.Unix(t, 0)
  274. } else if t, err := time.Parse(time.RFC3339, timeStr); err == nil {
  275. timeVal = t
  276. }
  277. // If time is given, but not parse-able, return an error
  278. if timeVal.IsZero() {
  279. http.Error(w, fmt.Sprintf("time must be a unix timestamp or RFC3339 value; illegal value given: %s", timeStr), http.StatusBadRequest)
  280. }
  281. }
  282. ctx := pds.promContexts.NewNamedContext(FrontendContextName)
  283. body, err := ctx.RawQuery(query, timeVal)
  284. if err != nil {
  285. proto.WriteResponse(w, proto.ToResponse(nil, fmt.Errorf("Error running query %s. Error: %s", query, err)))
  286. return
  287. }
  288. w.Write(body) // prometheusQueryRange is a proxy for /query_range against prometheus
  289. }
  290. func (pds *PrometheusDataSource) prometheusQueryRange(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  291. w.Header().Set("Content-Type", "application/json")
  292. w.Header().Set("Access-Control-Allow-Origin", "*")
  293. qp := httputil.NewQueryParams(r.URL.Query())
  294. query := qp.Get("query", "")
  295. if query == "" {
  296. fmt.Fprintf(w, "Error parsing query from request parameters.")
  297. return
  298. }
  299. start, end, duration, err := toStartEndStep(qp)
  300. if err != nil {
  301. fmt.Fprintf(w, "error: %s", err)
  302. return
  303. }
  304. ctx := pds.promContexts.NewNamedContext(FrontendContextName)
  305. body, err := ctx.RawQueryRange(query, start, end, duration)
  306. if err != nil {
  307. fmt.Fprintf(w, "Error running query %s. Error: %s", query, err)
  308. return
  309. }
  310. w.Write(body)
  311. }
  312. // thanosQuery is a proxy for /query against thanos
  313. func (pds *PrometheusDataSource) thanosQuery(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  314. w.Header().Set("Content-Type", "application/json")
  315. w.Header().Set("Access-Control-Allow-Origin", "*")
  316. if pds.thanosClient == nil {
  317. proto.WriteResponse(w, proto.ToResponse(nil, fmt.Errorf("ThanosDisabled")))
  318. return
  319. }
  320. qp := httputil.NewQueryParams(r.URL.Query())
  321. query := qp.Get("query", "")
  322. if query == "" {
  323. proto.WriteResponse(w, proto.ToResponse(nil, fmt.Errorf("Query Parameter 'query' is unset'")))
  324. return
  325. }
  326. // Attempt to parse time as either a unix timestamp or as an RFC3339 value
  327. var timeVal time.Time
  328. timeStr := qp.Get("time", "")
  329. if len(timeStr) > 0 {
  330. if t, err := strconv.ParseInt(timeStr, 10, 64); err == nil {
  331. timeVal = time.Unix(t, 0)
  332. } else if t, err := time.Parse(time.RFC3339, timeStr); err == nil {
  333. timeVal = t
  334. }
  335. // If time is given, but not parse-able, return an error
  336. if timeVal.IsZero() {
  337. http.Error(w, fmt.Sprintf("time must be a unix timestamp or RFC3339 value; illegal value given: %s", timeStr), http.StatusBadRequest)
  338. }
  339. }
  340. ctx := pds.thanosContexts.NewNamedContext(FrontendContextName)
  341. body, err := ctx.RawQuery(query, timeVal)
  342. if err != nil {
  343. proto.WriteResponse(w, proto.ToResponse(nil, fmt.Errorf("Error running query %s. Error: %s", query, err)))
  344. return
  345. }
  346. w.Write(body)
  347. }
  348. // thanosQueryRange is a proxy for /query_range against thanos
  349. func (pds *PrometheusDataSource) thanosQueryRange(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  350. w.Header().Set("Content-Type", "application/json")
  351. w.Header().Set("Access-Control-Allow-Origin", "*")
  352. if pds.thanosClient == nil {
  353. proto.WriteResponse(w, proto.ToResponse(nil, fmt.Errorf("ThanosDisabled")))
  354. return
  355. }
  356. qp := httputil.NewQueryParams(r.URL.Query())
  357. query := qp.Get("query", "")
  358. if query == "" {
  359. fmt.Fprintf(w, "Error parsing query from request parameters.")
  360. return
  361. }
  362. start, end, duration, err := toStartEndStep(qp)
  363. if err != nil {
  364. fmt.Fprintf(w, "error: %s", err)
  365. return
  366. }
  367. ctx := pds.thanosContexts.NewNamedContext(FrontendContextName)
  368. body, err := ctx.RawQueryRange(query, start, end, duration)
  369. if err != nil {
  370. fmt.Fprintf(w, "Error running query %s. Error: %s", query, err)
  371. return
  372. }
  373. w.Write(body)
  374. }
  375. // promtheusQueueState returns the current state of the prometheus and thanos request queues
  376. func (pds *PrometheusDataSource) prometheusQueueState(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
  377. w.Header().Set("Content-Type", "application/json")
  378. w.Header().Set("Access-Control-Allow-Origin", "*")
  379. promQueueState, err := GetPrometheusQueueState(pds.promClient, pds.promConfig)
  380. if err != nil {
  381. proto.WriteResponse(w, proto.ToResponse(nil, err))
  382. return
  383. }
  384. result := map[string]*PrometheusQueueState{
  385. "prometheus": promQueueState,
  386. }
  387. if pds.thanosClient != nil {
  388. thanosQueueState, err := GetPrometheusQueueState(pds.thanosClient, pds.thanosConfig.OpenCostPrometheusConfig)
  389. if err != nil {
  390. log.Warnf("Error getting Thanos queue state: %s", err)
  391. } else {
  392. result["thanos"] = thanosQueueState
  393. }
  394. }
  395. proto.WriteResponse(w, proto.ToResponse(result, nil))
  396. }
  397. // prometheusMetrics retrieves availability of Prometheus and Thanos metrics
  398. func (pds *PrometheusDataSource) prometheusMetrics(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
  399. w.Header().Set("Content-Type", "application/json")
  400. w.Header().Set("Access-Control-Allow-Origin", "*")
  401. promMetrics := GetPrometheusMetrics(pds.promClient, pds.promConfig, "")
  402. result := map[string][]*PrometheusDiagnostic{
  403. "prometheus": promMetrics,
  404. }
  405. if pds.thanosClient != nil {
  406. thanosMetrics := GetPrometheusMetrics(pds.thanosClient, pds.thanosConfig.OpenCostPrometheusConfig, pds.thanosConfig.Offset)
  407. result["thanos"] = thanosMetrics
  408. }
  409. proto.WriteResponse(w, proto.ToResponse(result, nil))
  410. }
  411. func (pds *PrometheusDataSource) PrometheusClient() prometheus.Client {
  412. return pds.promClient
  413. }
  414. func (pds *PrometheusDataSource) PrometheusConfig() *OpenCostPrometheusConfig {
  415. return pds.promConfig
  416. }
  417. func (pds *PrometheusDataSource) PrometheusContexts() *ContextFactory {
  418. return pds.promContexts
  419. }
  420. func (pds *PrometheusDataSource) ThanosClient() prometheus.Client {
  421. return pds.thanosClient
  422. }
  423. func (pds *PrometheusDataSource) ThanosConfig() *OpenCostThanosConfig {
  424. return pds.thanosConfig
  425. }
  426. func (pds *PrometheusDataSource) ThanosContexts() *ContextFactory {
  427. return pds.thanosContexts
  428. }
  429. func (pds *PrometheusDataSource) RegisterEndPoints(router *httprouter.Router) {
  430. // endpoints migrated from server
  431. router.GET("/validatePrometheus", pds.prometheusMetadata)
  432. router.GET("/prometheusRecordingRules", pds.prometheusRecordingRules)
  433. router.GET("/prometheusConfig", pds.prometheusConfig)
  434. router.GET("/prometheusTargets", pds.prometheusTargets)
  435. router.GET("/status", pds.status)
  436. // prom query proxies
  437. router.GET("/prometheusQuery", pds.prometheusQuery)
  438. router.GET("/prometheusQueryRange", pds.prometheusQueryRange)
  439. router.GET("/thanosQuery", pds.thanosQuery)
  440. router.GET("/thanosQueryRange", pds.thanosQueryRange)
  441. // diagnostics
  442. router.GET("/diagnostics/requestQueue", pds.prometheusQueueState)
  443. router.GET("/diagnostics/prometheusMetrics", pds.prometheusMetrics)
  444. }
  445. func (pds *PrometheusDataSource) RefreshInterval() time.Duration {
  446. return pds.promConfig.ScrapeInterval
  447. }
  448. func (pds *PrometheusDataSource) Metrics() source.MetricsQuerier {
  449. return pds.metricsQuerier
  450. }
  451. func (pds *PrometheusDataSource) ClusterMap() clusters.ClusterMap {
  452. return pds.clusterMap
  453. }
  454. // ClusterInfo returns the ClusterInfoProvider for the local cluster.
  455. func (pds *PrometheusDataSource) ClusterInfo() clusters.ClusterInfoProvider {
  456. return pds.clusterInfo
  457. }
  458. func (pds *PrometheusDataSource) BatchDuration() time.Duration {
  459. return pds.promConfig.MaxQueryDuration
  460. }
  461. func (pds *PrometheusDataSource) Resolution() time.Duration {
  462. return pds.promConfig.DataResolution
  463. }