import { makeSelector } from '../util'; import type { Metric, PromMetricsMetadata, DataProviderParams } from '../types'; type CustomRequest = (input: RequestInfo, init?: RequestInit) => Promise; interface APIResponse { status: 'success' | 'error'; data?: T; error?: string; warnings?: string[]; } const DEFAULT_SERIES_LIMIT = '40000'; const badRequest = 400; const unprocessableEntity = 422; const serviceUnavailable = 503; const CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT = 'codeModeSuggestionsIncomplete'; export class DataProvider { readonly metricNamesSuggestionLimit: number; private inputInRange: string; private suggestionsIncomplete: boolean; private readonly lookbackInterval = 60 * 60 * 1000 * 12; // 12 hours private variablesNames: string[] = []; private readonly url: string; private readonly errorHandler?: (error: any) => void; private readonly httpMethod: 'POST' | 'GET' = 'GET'; private readonly apiPrefix: string = '/api/v1'; private enableRequests: boolean = true; private readonly customRequest: CustomRequest = (input: RequestInfo, init?: RequestInit): Promise => fetch(input, init); metrics: string[]; labelKeys: string[]; metricsMetadata?: PromMetricsMetadata; durationVariablesCompletion: boolean; constructor(params: DataProviderParams) { this.inputInRange = ''; this.metricNamesSuggestionLimit = 1000; this.suggestionsIncomplete = false; this.url = params.url ? params.url : ''; this.errorHandler = params.httpErrorHandler; if (params.lookbackInterval) { this.lookbackInterval = params.lookbackInterval; } if (params.variablesNames) { this.variablesNames = [...params.variablesNames]; } this.durationVariablesCompletion = params.durationVariablesCompletion ?? true; if (params.request) { this.customRequest = params.request; } if (params.httpMethod) { this.httpMethod = params.httpMethod; } if (params.apiPrefix) { this.apiPrefix = params.apiPrefix; } // control whether DataProvider should actually send network requests if (typeof params.enableRequests !== 'undefined') { this.enableRequests = !!params.enableRequests; } this.metrics = []; this.labelKeys = []; } getVariablesNames(): string[] { return this.variablesNames; } setVariablesNames(variablesNames: string[]) { this.variablesNames = [...variablesNames]; } private buildRequest(endpoint: string, params: URLSearchParams) { let uri = endpoint; let body: URLSearchParams | null = params; if (this.httpMethod === 'GET') { uri = `${uri}?${params}`; body = null; } return { uri, body }; } private request(resource: string, init?: RequestInit): Promise { return this.customRequest(this.url + resource, init) .then((res) => { if (!res.ok && ![badRequest, unprocessableEntity, serviceUnavailable].includes(res.status)) { throw new Error(res.statusText); } return res; }) .then((res) => res.json()) .then((apiRes: APIResponse) => { if (apiRes.status === 'error') { const error = new Error(apiRes.error !== undefined ? apiRes.error : 'missing "error" field in response JSON'); if (this.errorHandler) { this.errorHandler(error); } throw error; } if (apiRes.data === undefined) { const error = new Error(apiRes.error !== undefined ? apiRes.error : 'missing "data" field in response JSON'); if (this.errorHandler) { this.errorHandler(error); } throw error; } return apiRes.data; }) .catch((error) => { if (this.errorHandler) { this.errorHandler(error); } throw error; }); } fetchSeries = async (selector: string, withLimit?: string): Promise[]> => { if (!this.enableRequests) { return [] as Record[]; } const end = new Date(); const start = new Date(end.getTime() - this.lookbackInterval); const url = `${this.apiPrefix}/series`; let urlParams: any = { start: start.toISOString() as string, end: end.toISOString() as string, }; if (selector) { urlParams['match[]'] = selector; } if (withLimit !== 'none') { urlParams = { ...urlParams, limit: withLimit ?? DEFAULT_SERIES_LIMIT }; } const request = this.buildRequest(url, new URLSearchParams(urlParams)); return await this.request[]>(request.uri, { method: this.httpMethod, body: request.body, }).catch(() => { return [] as Record[]; }); }; fetchLabels = async (selector: string): Promise => { if (!this.enableRequests) { return [] as string[]; } const end = new Date(); const start = new Date(end.getTime() - this.lookbackInterval); const url = `${this.apiPrefix}/labels`; const urlParams: any = { start: start.toISOString(), end: end.toISOString(), }; if (selector) { urlParams['match[]'] = selector; } const request = this.buildRequest(url, new URLSearchParams(urlParams)); return await this.request(request.uri, { method: this.httpMethod, body: request.body, }) .then((res) => { this.labelKeys = res; return res; }) .catch(() => { return [] as string[]; }); }; fetchLabelValues = async (labelName: string, selector: string): Promise => { if (!this.enableRequests) { return [] as string[]; } const end = new Date(); const start = new Date(end.getTime() - this.lookbackInterval); const url = `${this.apiPrefix}/label/${labelName}/values`; const urlParams: any = { start: start.toISOString(), end: end.toISOString(), }; if (selector) { urlParams['match[]'] = selector; } const request = this.buildRequest(url, new URLSearchParams(urlParams)); return await this.request(request.uri, { method: this.httpMethod, body: request.body, }).catch(() => { return [] as string[]; }); }; getAllMetricNames(): string[] { return this.metrics; } start = async () => { this.metrics = (await this.fetchLabelValues('__name__', makeSelector('', [], '__name__'))) || []; return Promise.all([ this.loadMetricsMetadata(), // this.fetchLabels() ]); }; async loadMetricsMetadata(): Promise { if (!this.enableRequests) { this.metricsMetadata = {} as PromMetricsMetadata; return this.metricsMetadata; } const request = this.buildRequest(`${this.apiPrefix}/metadata`, new URLSearchParams({})); this.metricsMetadata = await this.request(request.uri, { method: this.httpMethod, body: request.body, }).catch(() => { return {} as PromMetricsMetadata; }); return this.metricsMetadata || ({} as PromMetricsMetadata); } metricNamesToMetrics(metricNames: string[]): Metric[] { const result: Metric[] = metricNames.map((m) => { const metaItem = this.metricsMetadata?.[m]; return { name: m, help: metaItem?.help ?? '', type: metaItem?.type ?? '', }; }); return result; } private setInputInRange(textInput: string): void { this.inputInRange = textInput; } private enableAutocompleteSuggestionsUpdate(): void { this.suggestionsIncomplete = true; dispatchEvent( new CustomEvent(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, { detail: { limit: this.metricNamesSuggestionLimit, }, }), ); } get monacoSettings() { return { /** * Enable autocomplete suggestions update on every input change. * * @remarks * If fuzzy search is used in `getCompletions` to trim down results to improve performance, * we need to instruct Monaco to update the completions on every input change, so that the * completions reflect the current input. */ enableAutocompleteSuggestionsUpdate: this.enableAutocompleteSuggestionsUpdate.bind(this), inputInRange: this.inputInRange, setInputInRange: this.setInputInRange.bind(this), suggestionsIncomplete: this.suggestionsIncomplete, }; } }