2025-04-01 10:38:02 +09:00

298 lines
9.2 KiB
TypeScript

import { reject } from 'lodash';
import { Observable, OperatorFunction, ReplaySubject, Unsubscribable, of } from 'rxjs';
import { catchError, map, share } from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid';
import {
DataFrameJSON,
LoadingState,
PanelData,
TimeRange,
dataFrameFromJSON,
getDefaultTimeRange,
preProcessPanelData,
rangeUtil,
withLoadingIndicator,
} from '@grafana/data';
import { DataSourceWithBackend, FetchResponse, getDataSourceSrv, toDataQueryError } from '@grafana/runtime';
import { t } from 'app/core/internationalization';
import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { cancelNetworkRequestsOnUnsubscribe } from 'app/features/query/state/processing/canceler';
import { setStructureRevision } from 'app/features/query/state/processing/revision';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { LinkError, createDAGFromQueriesSafe, getDescendants } from '../components/rule-editor/dag';
import { getTimeRangeForExpression } from '../utils/timeRange';
export interface AlertingQueryResult {
error?: string;
status?: number; // HTTP status error
frames: DataFrameJSON[];
}
export interface AlertingQueryResponse {
results: Record<string, AlertingQueryResult>;
}
export class AlertingQueryRunner {
private subject: ReplaySubject<Record<string, PanelData>>;
private subscription?: Unsubscribable;
private lastResult: Record<string, PanelData>;
constructor(
private backendSrv = getBackendSrv(),
private dataSourceSrv = getDataSourceSrv()
) {
this.subject = new ReplaySubject(1);
this.lastResult = {};
}
get(): Observable<Record<string, PanelData>> {
return this.subject.asObservable();
}
async run(queries: AlertQuery[], condition: string) {
const queriesToRun = await this.prepareQueries(queries);
// if we don't have any queries to run we just bail
if (queriesToRun.length === 0) {
return;
}
// if the condition isn't part of the queries to run, try to run the alert rule without it.
// It indicates that the "condition" node points to a non-existent node. We still want to be able to evaluate the other nodes.
const isConditionAvailable = queriesToRun.some((query) => query.refId === condition);
const ruleCondition = isConditionAvailable ? condition : '';
this.subscription = runRequest(this.backendSrv, queriesToRun, ruleCondition).subscribe({
next: (dataPerQuery) => {
const nextResult = applyChange(dataPerQuery, (refId, data) => {
const previous = this.lastResult[refId];
const preProcessed = preProcessPanelData(data, previous);
return setStructureRevision(preProcessed, previous);
});
// add link errors to the panelData and mark them as errors
const [_, linkErrors] = createDAGFromQueriesSafe(queries);
linkErrors.forEach((linkError) => {
nextResult[linkError.source] = createLinkErrorPanelData(linkError);
});
this.lastResult = nextResult;
this.subject.next(this.lastResult);
},
error: (error: Error) => {
this.lastResult = mapErrorToPanelData(this.lastResult, error);
this.subject.next(this.lastResult);
},
});
}
// this function will omit any invalid queries and all of its descendants from the list of queries
// to do this we will convert the list of queries into a DAG and walk the invalid node's output edges recursively
async prepareQueries(queries: AlertQuery[]): Promise<AlertQuery[]> {
const queriesToExclude: string[] = [];
// find all invalid nodes and omit those
for (const query of queries) {
const refId = query.model.refId;
// expression queries cannot be excluded / filtered out
if (isExpressionQuery(query.model)) {
continue;
}
const dataSourceInstance = await this.dataSourceSrv.get(query.datasourceUid);
const skipRunningQuery =
dataSourceInstance instanceof DataSourceWithBackend &&
dataSourceInstance.filterQuery &&
!dataSourceInstance.filterQuery(query.model);
if (skipRunningQuery) {
queriesToExclude.push(refId);
}
}
// exclude nodes that failed to link and their child nodes from the final queries array by trying to parse the graph
// ⚠️ also make sure all dependent nodes are omitted, otherwise we will be evaluating a broken graph with missing references
const [cleanGraph] = createDAGFromQueriesSafe(queries);
const cleanNodes = Object.keys(cleanGraph.nodes);
// find descendant nodes of data queries that have been excluded
queriesToExclude.forEach((refId) => {
const descendants = getDescendants(refId, cleanGraph);
queriesToExclude.push(...descendants);
});
// also exclude all nodes that aren't in cleanGraph, this means they point to other broken nodes
const nodesNotInGraph = queries.filter((query) => !cleanNodes.includes(query.refId));
nodesNotInGraph.forEach((node) => {
queriesToExclude.push(node.refId);
});
return reject(queries, (query) => queriesToExclude.includes(query.refId));
}
cancel() {
if (!this.subscription) {
return;
}
this.subscription.unsubscribe();
let requestIsRunning = false;
const nextResult = applyChange(this.lastResult, (refId, data) => {
if (data.state === LoadingState.Loading) {
requestIsRunning = true;
}
return {
...data,
state: LoadingState.Done,
};
});
if (requestIsRunning) {
this.subject.next(nextResult);
}
}
destroy() {
if (this.subject) {
this.subject.complete();
}
this.cancel();
}
}
const runRequest = (
backendSrv: BackendSrv,
queries: AlertQuery[],
condition: string
): Observable<Record<string, PanelData>> => {
const initial = initialState(queries, LoadingState.Loading);
const request = {
data: { data: queries, condition },
url: '/api/v1/eval',
method: 'POST',
requestId: uuidv4(),
};
return withLoadingIndicator({
whileLoading: initial,
source: backendSrv.fetch<AlertingQueryResponse>(request).pipe(
mapToPanelData(initial),
catchError((error) => of(mapErrorToPanelData(initial, error))),
cancelNetworkRequestsOnUnsubscribe(backendSrv, request.requestId),
share()
),
});
};
const initialState = (queries: AlertQuery[], state: LoadingState): Record<string, PanelData> => {
return queries.reduce((dataByQuery: Record<string, PanelData>, query) => {
dataByQuery[query.refId] = {
state,
series: [],
timeRange: getTimeRange(query, queries),
};
return dataByQuery;
}, {});
};
const getTimeRange = (query: AlertQuery, queries: AlertQuery[]): TimeRange => {
if (isExpressionQuery(query.model)) {
const relative = getTimeRangeForExpression(query.model, queries);
return rangeUtil.relativeToTimeRange(relative);
}
if (!query.relativeTimeRange) {
console.warn(`Query with refId: ${query.refId} did not have any relative time range, using default.`);
return getDefaultTimeRange();
}
return rangeUtil.relativeToTimeRange(query.relativeTimeRange);
};
const mapToPanelData = (
dataByQuery: Record<string, PanelData>
): OperatorFunction<FetchResponse<AlertingQueryResponse>, Record<string, PanelData>> => {
return map((response) => {
const { data } = response;
const results: Record<string, PanelData> = {};
for (const [refId, result] of Object.entries(data.results)) {
const { error, status, frames = [] } = result;
// extract errors from the /eval results
const errors = error ? [{ message: error, refId, status }] : [];
results[refId] = {
errors,
timeRange: dataByQuery[refId].timeRange,
state: LoadingState.Done,
series: frames.map(dataFrameFromJSON),
};
}
return results;
});
};
const mapErrorToPanelData = (lastResult: Record<string, PanelData>, error: Error): Record<string, PanelData> => {
const queryError = toDataQueryError(error);
return applyChange(lastResult, (_refId, data) => {
if (data.state === LoadingState.Error) {
return data;
}
return {
...data,
state: LoadingState.Error,
error: queryError,
};
});
};
const applyChange = (
initial: Record<string, PanelData>,
change: (refId: string, data: PanelData) => PanelData
): Record<string, PanelData> => {
const nextResult: Record<string, PanelData> = {};
for (const [refId, data] of Object.entries(initial)) {
nextResult[refId] = change(refId, data);
}
return nextResult;
};
const createLinkErrorPanelData = (error: LinkError): PanelData => ({
series: [],
state: LoadingState.Error,
errors: [
{
message: createLinkErrorMessage(error),
},
],
timeRange: getDefaultTimeRange(),
});
function createLinkErrorMessage(error: LinkError): string {
const isSelfReference = error.source === error.target;
return isSelfReference
? t('alerting.dag.self-reference', "You can't link an expression to itself")
: t(
'alerting.dag.missing-reference',
`Expression "{{source}}" failed to run because "{{target}}" is missing or also failed.`,
{
source: error.source,
target: error.target,
}
);
}