1// Copyright 2021 The Prometheus Authors
2// Licensed under the Apache License, Version 2.0 (the "License");
3// you may not use this file except in compliance with the License.
4// You may obtain a copy of the License at
5//
6// http://www.apache.org/licenses/LICENSE-2.0
7//
8// Unless required by applicable law or agreed to in writing, software
9// distributed under the License is distributed on an "AS IS" BASIS,
10// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11// See the License for the specific language governing permissions and
12// limitations under the License.
13
14import { FetchFn } from '.';
15import { Matcher } from '../types';
16import { labelMatchersToString } from '../parser';
17import LRUCache from 'lru-cache';
18
19export interface MetricMetadata {
20  type: string;
21  help: string;
22}
23
24export interface PrometheusClient {
25  labelNames(metricName?: string): Promise<string[]>;
26
27  // labelValues return a list of the value associated to the given labelName.
28  // In case a metric is provided, then the list of values is then associated to the couple <MetricName, LabelName>
29  labelValues(labelName: string, metricName?: string, matchers?: Matcher[]): Promise<string[]>;
30
31  metricMetadata(): Promise<Record<string, MetricMetadata[]>>;
32
33  series(metricName: string, matchers?: Matcher[], labelName?: string): Promise<Map<string, string>[]>;
34
35  // metricNames returns a list of suggestions for the metric name given the `prefix`.
36  // Note that the returned list can be a superset of those suggestions for the prefix (i.e., including ones without the
37  // prefix), as codemirror will filter these out when displaying suggestions to the user.
38  metricNames(prefix?: string): Promise<string[]>;
39}
40
41export interface CacheConfig {
42  // maxAge is the maximum amount of time that a cached completion item is valid before it needs to be refreshed.
43  // It is in milliseconds. Default value:  300 000 (5min)
44  maxAge?: number;
45  // the cache can be initialized with a list of metrics
46  initialMetricList?: string[];
47}
48
49export interface PrometheusConfig {
50  url: string;
51  lookbackInterval?: number;
52  // eslint-disable-next-line @typescript-eslint/no-explicit-any
53  httpErrorHandler?: (error: any) => void;
54  fetchFn?: FetchFn;
55  // cache will allow user to change the configuration of the cached Prometheus client (which is used by default)
56  cache?: CacheConfig;
57  httpMethod?: 'POST' | 'GET';
58  apiPrefix?: string;
59}
60
61interface APIResponse<T> {
62  status: 'success' | 'error';
63  data?: T;
64  error?: string;
65  warnings?: string[];
66}
67
68// These are status codes where the Prometheus API still returns a valid JSON body,
69// with an error encoded within the JSON.
70const badRequest = 400;
71const unprocessableEntity = 422;
72const serviceUnavailable = 503;
73
74// HTTPPrometheusClient is the HTTP client that should be used to get some information from the different endpoint provided by prometheus.
75export class HTTPPrometheusClient implements PrometheusClient {
76  private readonly lookbackInterval = 60 * 60 * 1000 * 12; //12 hours
77  private readonly url: string;
78  // eslint-disable-next-line @typescript-eslint/no-explicit-any
79  private readonly errorHandler?: (error: any) => void;
80  private readonly httpMethod: 'POST' | 'GET' = 'POST';
81  private readonly apiPrefix: string = '/api/v1';
82  // For some reason, just assigning via "= fetch" here does not end up executing fetch correctly
83  // when calling it, thus the indirection via another function wrapper.
84  private readonly fetchFn: FetchFn = (input: RequestInfo, init?: RequestInit): Promise<Response> => fetch(input, init);
85
86  constructor(config: PrometheusConfig) {
87    this.url = config.url;
88    this.errorHandler = config.httpErrorHandler;
89    if (config.lookbackInterval) {
90      this.lookbackInterval = config.lookbackInterval;
91    }
92    if (config.fetchFn) {
93      this.fetchFn = config.fetchFn;
94    }
95    if (config.httpMethod) {
96      this.httpMethod = config.httpMethod;
97    }
98    if (config.apiPrefix) {
99      this.apiPrefix = config.apiPrefix;
100    }
101  }
102
103  labelNames(metricName?: string): Promise<string[]> {
104    const end = new Date();
105    const start = new Date(end.getTime() - this.lookbackInterval);
106    if (metricName === undefined || metricName === '') {
107      const request = this.buildRequest(
108        this.labelsEndpoint(),
109        new URLSearchParams({
110          start: start.toISOString(),
111          end: end.toISOString(),
112        })
113      );
114      // See https://prometheus.io/docs/prometheus/latest/querying/api/#getting-label-names
115      return this.fetchAPI<string[]>(request.uri, {
116        method: this.httpMethod,
117        body: request.body,
118      }).catch((error) => {
119        if (this.errorHandler) {
120          this.errorHandler(error);
121        }
122        return [];
123      });
124    }
125
126    return this.series(metricName).then((series) => {
127      const labelNames = new Set<string>();
128      for (const labelSet of series) {
129        for (const [key] of Object.entries(labelSet)) {
130          if (key === '__name__') {
131            continue;
132          }
133          labelNames.add(key);
134        }
135      }
136      return Array.from(labelNames);
137    });
138  }
139
140  // labelValues return a list of the value associated to the given labelName.
141  // In case a metric is provided, then the list of values is then associated to the couple <MetricName, LabelName>
142  labelValues(labelName: string, metricName?: string, matchers?: Matcher[]): Promise<string[]> {
143    const end = new Date();
144    const start = new Date(end.getTime() - this.lookbackInterval);
145
146    if (!metricName || metricName.length === 0) {
147      const params: URLSearchParams = new URLSearchParams({
148        start: start.toISOString(),
149        end: end.toISOString(),
150      });
151      // See https://prometheus.io/docs/prometheus/latest/querying/api/#querying-label-values
152      return this.fetchAPI<string[]>(`${this.labelValuesEndpoint().replace(/:name/gi, labelName)}?${params}`).catch((error) => {
153        if (this.errorHandler) {
154          this.errorHandler(error);
155        }
156        return [];
157      });
158    }
159
160    return this.series(metricName, matchers, labelName).then((series) => {
161      const labelValues = new Set<string>();
162      for (const labelSet of series) {
163        for (const [key, value] of Object.entries(labelSet)) {
164          if (key === '__name__') {
165            continue;
166          }
167          if (key === labelName) {
168            labelValues.add(value);
169          }
170        }
171      }
172      return Array.from(labelValues);
173    });
174  }
175
176  metricMetadata(): Promise<Record<string, MetricMetadata[]>> {
177    return this.fetchAPI<Record<string, MetricMetadata[]>>(this.metricMetadataEndpoint()).catch((error) => {
178      if (this.errorHandler) {
179        this.errorHandler(error);
180      }
181      return {};
182    });
183  }
184
185  series(metricName: string, matchers?: Matcher[], labelName?: string): Promise<Map<string, string>[]> {
186    const end = new Date();
187    const start = new Date(end.getTime() - this.lookbackInterval);
188    const request = this.buildRequest(
189      this.seriesEndpoint(),
190      new URLSearchParams({
191        start: start.toISOString(),
192        end: end.toISOString(),
193        'match[]': labelMatchersToString(metricName, matchers, labelName),
194      })
195    );
196    // See https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers
197    return this.fetchAPI<Map<string, string>[]>(request.uri, {
198      method: this.httpMethod,
199      body: request.body,
200    }).catch((error) => {
201      if (this.errorHandler) {
202        this.errorHandler(error);
203      }
204      return [];
205    });
206  }
207
208  metricNames(): Promise<string[]> {
209    return this.labelValues('__name__');
210  }
211
212  private fetchAPI<T>(resource: string, init?: RequestInit): Promise<T> {
213    return this.fetchFn(this.url + resource, init)
214      .then((res) => {
215        if (!res.ok && ![badRequest, unprocessableEntity, serviceUnavailable].includes(res.status)) {
216          throw new Error(res.statusText);
217        }
218        return res;
219      })
220      .then((res) => res.json())
221      .then((apiRes: APIResponse<T>) => {
222        if (apiRes.status === 'error') {
223          throw new Error(apiRes.error !== undefined ? apiRes.error : 'missing "error" field in response JSON');
224        }
225        if (apiRes.data === undefined) {
226          throw new Error('missing "data" field in response JSON');
227        }
228        return apiRes.data;
229      });
230  }
231
232  private buildRequest(endpoint: string, params: URLSearchParams) {
233    let uri = endpoint;
234    let body: URLSearchParams | null = params;
235    if (this.httpMethod === 'GET') {
236      uri = `${uri}?${params}`;
237      body = null;
238    }
239    return { uri, body };
240  }
241
242  private labelsEndpoint(): string {
243    return `${this.apiPrefix}/labels`;
244  }
245  private labelValuesEndpoint(): string {
246    return `${this.apiPrefix}/label/:name/values`;
247  }
248  private seriesEndpoint(): string {
249    return `${this.apiPrefix}/series`;
250  }
251  private metricMetadataEndpoint(): string {
252    return `${this.apiPrefix}/metadata`;
253  }
254}
255
256class Cache {
257  // completeAssociation is the association between a metric name, a label name and the possible label values
258  private readonly completeAssociation: LRUCache<string, Map<string, Set<string>>>;
259  // metricMetadata is the association between a metric name and the associated metadata
260  private metricMetadata: Record<string, MetricMetadata[]>;
261  private labelValues: LRUCache<string, string[]>;
262  private labelNames: string[];
263
264  constructor(config?: CacheConfig) {
265    const maxAge = config && config.maxAge ? config.maxAge : 5 * 60 * 1000;
266    this.completeAssociation = new LRUCache<string, Map<string, Set<string>>>(maxAge);
267    this.metricMetadata = {};
268    this.labelValues = new LRUCache<string, string[]>(maxAge);
269    this.labelNames = [];
270    if (config?.initialMetricList) {
271      this.setLabelValues('__name__', config.initialMetricList);
272    }
273  }
274
275  setAssociations(metricName: string, series: Map<string, string>[]): void {
276    series.forEach((labelSet: Map<string, string>) => {
277      let currentAssociation = this.completeAssociation.get(metricName);
278      if (!currentAssociation) {
279        currentAssociation = new Map<string, Set<string>>();
280        this.completeAssociation.set(metricName, currentAssociation);
281      }
282
283      for (const [key, value] of Object.entries(labelSet)) {
284        if (key === '__name__') {
285          continue;
286        }
287        const labelValues = currentAssociation.get(key);
288        if (labelValues === undefined) {
289          currentAssociation.set(key, new Set<string>([value]));
290        } else {
291          labelValues.add(value);
292        }
293      }
294    });
295  }
296
297  setMetricMetadata(metadata: Record<string, MetricMetadata[]>): void {
298    this.metricMetadata = metadata;
299  }
300
301  getMetricMetadata(): Record<string, MetricMetadata[]> {
302    return this.metricMetadata;
303  }
304
305  setLabelNames(labelNames: string[]): void {
306    this.labelNames = labelNames;
307  }
308
309  getLabelNames(metricName?: string): string[] {
310    if (!metricName || metricName.length === 0) {
311      return this.labelNames;
312    }
313    const labelSet = this.completeAssociation.get(metricName);
314    return labelSet ? Array.from(labelSet.keys()) : [];
315  }
316
317  setLabelValues(labelName: string, labelValues: string[]): void {
318    this.labelValues.set(labelName, labelValues);
319  }
320
321  getLabelValues(labelName: string, metricName?: string): string[] {
322    if (!metricName || metricName.length === 0) {
323      const result = this.labelValues.get(labelName);
324      return result ? result : [];
325    }
326
327    const labelSet = this.completeAssociation.get(metricName);
328    if (labelSet) {
329      const labelValues = labelSet.get(labelName);
330      return labelValues ? Array.from(labelValues) : [];
331    }
332    return [];
333  }
334}
335
336export class CachedPrometheusClient implements PrometheusClient {
337  private readonly cache: Cache;
338  private readonly client: PrometheusClient;
339
340  constructor(client: PrometheusClient, config?: CacheConfig) {
341    this.client = client;
342    this.cache = new Cache(config);
343  }
344
345  labelNames(metricName?: string): Promise<string[]> {
346    const cachedLabel = this.cache.getLabelNames(metricName);
347    if (cachedLabel && cachedLabel.length > 0) {
348      return Promise.resolve(cachedLabel);
349    }
350
351    if (metricName === undefined || metricName === '') {
352      return this.client.labelNames().then((labelNames) => {
353        this.cache.setLabelNames(labelNames);
354        return labelNames;
355      });
356    }
357    return this.series(metricName).then(() => {
358      return this.cache.getLabelNames(metricName);
359    });
360  }
361
362  labelValues(labelName: string, metricName?: string): Promise<string[]> {
363    const cachedLabel = this.cache.getLabelValues(labelName, metricName);
364    if (cachedLabel && cachedLabel.length > 0) {
365      return Promise.resolve(cachedLabel);
366    }
367
368    if (metricName === undefined || metricName === '') {
369      return this.client.labelValues(labelName).then((labelValues) => {
370        this.cache.setLabelValues(labelName, labelValues);
371        return labelValues;
372      });
373    }
374
375    return this.series(metricName).then(() => {
376      return this.cache.getLabelValues(labelName, metricName);
377    });
378  }
379
380  metricMetadata(): Promise<Record<string, MetricMetadata[]>> {
381    const cachedMetadata = this.cache.getMetricMetadata();
382    if (cachedMetadata && Object.keys(cachedMetadata).length > 0) {
383      return Promise.resolve(cachedMetadata);
384    }
385
386    return this.client.metricMetadata().then((metadata) => {
387      this.cache.setMetricMetadata(metadata);
388      return this.cache.getMetricMetadata();
389    });
390  }
391
392  series(metricName: string): Promise<Map<string, string>[]> {
393    return this.client.series(metricName).then((series) => {
394      this.cache.setAssociations(metricName, series);
395      return series;
396    });
397  }
398
399  metricNames(): Promise<string[]> {
400    return this.labelValues('__name__');
401  }
402}
403