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