grafana/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts

569 lines
18 KiB
TypeScript

import { capitalize } from 'lodash';
import pluralize from 'pluralize';
import {
QueryBuilderOperation,
QueryBuilderOperationDefinition,
QueryBuilderOperationParamDef,
QueryBuilderOperationParamValue,
VisualQuery,
VisualQueryModeller,
} from '@grafana/experimental';
import { escapeLabelValueInExactSelector } from '../languageUtils';
import { FUNCTIONS } from '../syntax';
import { LabelParamEditor } from './components/LabelParamEditor';
import { LokiOperationId, LokiOperationOrder, LokiVisualQuery, LokiVisualQueryOperationCategory } from './types';
export function createRangeOperation(
name: string,
isRangeOperationWithGrouping?: boolean
): QueryBuilderOperationDefinition {
const params = [getRangeVectorParamDef()];
const defaultParams = ['$__auto'];
let paramChangedHandler = undefined;
if (name === LokiOperationId.QuantileOverTime) {
defaultParams.push('0.95');
params.push({
name: 'Quantile',
type: 'number',
});
}
if (isRangeOperationWithGrouping) {
params.push({
name: 'By label',
type: 'string',
restParam: true,
optional: true,
});
paramChangedHandler = getOnLabelAddedHandler(`__${name}_by`);
}
return {
id: name,
name: getLokiOperationDisplayName(name),
params: params,
defaultParams,
alternativesKey: 'range function',
category: LokiVisualQueryOperationCategory.RangeFunctions,
orderRank: LokiOperationOrder.RangeVectorFunction,
renderer: operationWithRangeVectorRenderer,
addOperationHandler: addLokiOperation,
paramChangedHandler,
explainHandler: (op, def) => {
let opDocs = FUNCTIONS.find((x) => x.insertText === op.id)?.documentation ?? '';
if (op.params[0] === '$__auto') {
return `${opDocs} \`$__auto\` is a variable that will be replaced with the [value of step](https://grafana.com/docs/grafana/next/datasources/loki/query-editor/#options) for range queries and with the value of the selected time range (calculated to - from) for instant queries.`;
} else {
return `${opDocs} The [range vector](https://grafana.com/docs/loki/latest/logql/metric_queries/#range-vector-aggregation) is set to \`${op.params[0]}\`.`;
}
},
};
}
export function createRangeOperationWithGrouping(name: string): QueryBuilderOperationDefinition[] {
const rangeOperation = createRangeOperation(name, true);
// Copy range operation params without the last param
const params = rangeOperation.params.slice(0, -1);
const operations: QueryBuilderOperationDefinition[] = [
rangeOperation,
{
id: `__${name}_by`,
name: `${getLokiOperationDisplayName(name)} by`,
params: [
...params,
{
name: 'Label',
type: 'string',
restParam: true,
optional: true,
editor: LabelParamEditor,
},
],
defaultParams: [...rangeOperation.defaultParams, ''],
alternativesKey: 'range function with grouping',
category: LokiVisualQueryOperationCategory.RangeFunctions,
renderer: getRangeAggregationWithGroupingRenderer(name, 'by'),
paramChangedHandler: getLastLabelRemovedHandler(name),
explainHandler: getAggregationExplainer(name, 'by'),
addOperationHandler: addLokiOperation,
hideFromList: true,
},
{
id: `__${name}_without`,
name: `${getLokiOperationDisplayName(name)} without`,
params: [
...params,
{
name: 'Label',
type: 'string',
restParam: true,
optional: true,
editor: LabelParamEditor,
},
],
defaultParams: [...rangeOperation.defaultParams, ''],
alternativesKey: 'range function with grouping',
category: LokiVisualQueryOperationCategory.RangeFunctions,
renderer: getRangeAggregationWithGroupingRenderer(name, 'without'),
paramChangedHandler: getLastLabelRemovedHandler(name),
explainHandler: getAggregationExplainer(name, 'without'),
addOperationHandler: addLokiOperation,
hideFromList: true,
},
];
return operations;
}
export function getRangeAggregationWithGroupingRenderer(aggregation: string, grouping: 'by' | 'without') {
return function aggregationRenderer(
model: QueryBuilderOperation,
def: QueryBuilderOperationDefinition,
innerExpr: string
) {
const restParamIndex = def.params.findIndex((param) => param.restParam);
const params = model.params.slice(0, restParamIndex);
const restParams = model.params.slice(restParamIndex);
if (params.length === 2 && aggregation === LokiOperationId.QuantileOverTime) {
return `${aggregation}(${params[1]}, ${innerExpr} [${params[0]}]) ${grouping} (${restParams.join(', ')})`;
}
return `${aggregation}(${innerExpr} [${params[0]}]) ${grouping} (${restParams.join(', ')})`;
};
}
function operationWithRangeVectorRenderer(
model: QueryBuilderOperation,
def: QueryBuilderOperationDefinition,
innerExpr: string
) {
const params = model.params ?? [];
const rangeVector = params[0] ?? '$__auto';
// QuantileOverTime is only range vector with more than one param
if (params.length === 2 && model.id === LokiOperationId.QuantileOverTime) {
const quantile = params[1];
return `${model.id}(${quantile}, ${innerExpr} [${rangeVector}])`;
}
return `${model.id}(${innerExpr} [${params[0] ?? '$__auto'}])`;
}
export function labelFilterRenderer(
model: QueryBuilderOperation,
def: QueryBuilderOperationDefinition,
innerExpr: string
) {
const integerOperators = ['<', '<=', '>', '>='];
if (integerOperators.includes(String(model.params[1]))) {
return `${innerExpr} | ${model.params[0]} ${model.params[1]} ${model.params[2]}`;
}
return `${innerExpr} | ${model.params[0]} ${model.params[1]} \`${model.params[2]}\``;
}
export function isConflictingFilter(
operation: QueryBuilderOperation,
queryOperations: QueryBuilderOperation[]
): boolean {
if (!operation) {
return false;
}
const operationIsNegative = operation.params[1].toString().startsWith('!');
const candidates = queryOperations.filter(
(queryOperation) =>
queryOperation.id === LokiOperationId.LabelFilter &&
queryOperation.params[0] === operation.params[0] &&
queryOperation.params[2] === operation.params[2]
);
const conflict = candidates.some((candidate) => {
if (operationIsNegative && candidate.params[1].toString().startsWith('!') === false) {
return true;
}
if (operationIsNegative === false && candidate.params[1].toString().startsWith('!')) {
return true;
}
return false;
});
return conflict;
}
export function pipelineRenderer(
model: QueryBuilderOperation,
def: QueryBuilderOperationDefinition,
innerExpr: string
) {
switch (model.id) {
case LokiOperationId.Logfmt:
const [strict = false, keepEmpty = false, ...labels] = model.params;
return `${innerExpr} | logfmt${strict ? ' --strict' : ''}${keepEmpty ? ' --keep-empty' : ''} ${labels
.filter((label) => label)
.join(', ')}`.trim();
case LokiOperationId.Json:
return `${innerExpr} | json ${model.params.filter((param) => param).join(', ')}`.trim();
case LokiOperationId.Drop:
return `${innerExpr} | drop ${model.params.filter((param) => param).join(', ')}`.trim();
case LokiOperationId.Keep:
return `${innerExpr} | keep ${model.params.filter((param) => param).join(', ')}`.trim();
default:
return `${innerExpr} | ${model.id}`;
}
}
function isRangeVectorFunction(def: QueryBuilderOperationDefinition) {
return def.category === LokiVisualQueryOperationCategory.RangeFunctions;
}
function getIndexOfOrLast(
operations: QueryBuilderOperation[],
queryModeller: VisualQueryModeller,
condition: (def: QueryBuilderOperationDefinition) => boolean
) {
const index = operations.findIndex((x) => {
const opDef = queryModeller.getOperationDefinition(x.id);
if (!opDef) {
return false;
}
return condition(opDef);
});
return index === -1 ? operations.length : index;
}
export function addLokiOperation(
def: QueryBuilderOperationDefinition,
query: LokiVisualQuery,
modeller: VisualQueryModeller
): LokiVisualQuery {
const newOperation: QueryBuilderOperation = {
id: def.id,
params: def.defaultParams,
};
const operations = [...query.operations];
const existingRangeVectorFunction = operations.find((x) => {
const opDef = modeller.getOperationDefinition(x.id);
if (!opDef) {
return false;
}
return isRangeVectorFunction(opDef);
});
switch (def.category) {
case LokiVisualQueryOperationCategory.Aggregations:
case LokiVisualQueryOperationCategory.Functions:
// If we are adding a function but we have not range vector function yet add one
if (!existingRangeVectorFunction) {
const placeToInsert = getIndexOfOrLast(
operations,
modeller,
(def) => def.category === LokiVisualQueryOperationCategory.Functions
);
operations.splice(placeToInsert, 0, { id: LokiOperationId.Rate, params: ['$__auto'] });
}
operations.push(newOperation);
break;
case LokiVisualQueryOperationCategory.RangeFunctions:
// If adding a range function and range function is already added replace it
if (existingRangeVectorFunction) {
const index = operations.indexOf(existingRangeVectorFunction);
operations[index] = newOperation;
break;
}
// Add range functions after any formats, line filters and label filters
default:
const placeToInsert = getIndexOfOrLast(
operations,
modeller,
(x) => (def.orderRank ?? 100) < (x.orderRank ?? 100)
);
operations.splice(placeToInsert, 0, newOperation);
break;
}
return {
...query,
operations,
};
}
export function addNestedQueryHandler(def: QueryBuilderOperationDefinition, query: LokiVisualQuery): LokiVisualQuery {
return {
...query,
binaryQueries: [
...(query.binaryQueries ?? []),
{
operator: '/',
query,
},
],
};
}
export function getLineFilterRenderer(operation: string, caseInsensitive?: boolean) {
return function lineFilterRenderer(
model: QueryBuilderOperation,
def: QueryBuilderOperationDefinition,
innerExpr: string
) {
const hasBackticks = model.params.some((param) => typeof param === 'string' && param.includes('`'));
const delimiter = hasBackticks ? '"' : '`';
let params;
if (hasBackticks) {
params = model.params.map((param) =>
typeof param === 'string' ? escapeLabelValueInExactSelector(param) : param
);
} else {
params = model.params;
}
if (caseInsensitive) {
return `${innerExpr} ${operation} ${delimiter}(?i)${params.join(`${delimiter} or ${delimiter}(?i)`)}${delimiter}`;
}
return `${innerExpr} ${operation} ${delimiter}${params.join(`${delimiter} or ${delimiter}`)}${delimiter}`;
};
}
function getRangeVectorParamDef(): QueryBuilderOperationParamDef {
return {
name: 'Range',
type: 'string',
options: ['$__auto', '1m', '5m', '10m', '1h', '24h'],
};
}
export function getOperationParamId(operationId: string, paramIndex: number) {
return `operations.${operationId}.param.${paramIndex}`;
}
export function getOnLabelAddedHandler(changeToOperationId: string) {
return function onParamChanged(index: number, op: QueryBuilderOperation, def: QueryBuilderOperationDefinition) {
// Check if we actually have the label param. As it's optional the aggregation can have one less, which is the
// case of just simple aggregation without label. When user adds the label it now has the same number of params
// as its definition, and now we can change it to its `_by` variant.
if (op.params.length === def.params.length) {
return {
...op,
id: changeToOperationId,
};
}
return op;
};
}
/**
* Very simple poc implementation, needs to be modified to support all aggregation operators
*/
export function getAggregationExplainer(aggregationName: string, mode: 'by' | 'without' | '') {
return function aggregationExplainer(model: QueryBuilderOperation) {
const labels = model.params.map((label) => `\`${label}\``).join(' and ');
const labelWord = pluralize('label', model.params.length);
switch (mode) {
case 'by':
return `Calculates ${aggregationName} over dimensions while preserving ${labelWord} ${labels}.`;
case 'without':
return `Calculates ${aggregationName} over the dimensions ${labels}. All other labels are preserved.`;
default:
return `Calculates ${aggregationName} over the dimensions.`;
}
};
}
/**
* This function will transform operations without labels to their plan aggregation operation
*/
export function getLastLabelRemovedHandler(changeToOperationId: string) {
return function onParamChanged(index: number, op: QueryBuilderOperation, def: QueryBuilderOperationDefinition) {
// If definition has more params then is defined there are no optional rest params anymore.
// We then transform this operation into a different one
if (op.params.length < def.params.length) {
return {
...op,
id: changeToOperationId,
};
}
return op;
};
}
export function getLokiOperationDisplayName(funcName: string) {
return capitalize(funcName.replace(/_/g, ' '));
}
export function defaultAddOperationHandler<T extends VisualQuery>(def: QueryBuilderOperationDefinition, query: T) {
const newOperation: QueryBuilderOperation = {
id: def.id,
params: def.defaultParams,
};
return {
...query,
operations: [...query.operations, newOperation],
};
}
export function createAggregationOperation(
name: string,
overrides: Partial<QueryBuilderOperationDefinition> = {}
): QueryBuilderOperationDefinition[] {
const operations: QueryBuilderOperationDefinition[] = [
{
id: name,
name: getLokiOperationDisplayName(name),
params: [
{
name: 'By label',
type: 'string',
restParam: true,
optional: true,
},
],
defaultParams: [],
alternativesKey: 'plain aggregations',
category: LokiVisualQueryOperationCategory.Aggregations,
renderer: functionRendererLeft,
paramChangedHandler: getOnLabelAddedHandler(`__${name}_by`),
explainHandler: getAggregationExplainer(name, ''),
addOperationHandler: defaultAddOperationHandler,
...overrides,
},
{
id: `__${name}_by`,
name: `${getLokiOperationDisplayName(name)} by`,
params: [
{
name: 'Label',
type: 'string',
restParam: true,
optional: true,
editor: LabelParamEditor,
},
],
defaultParams: [''],
alternativesKey: 'aggregations by',
category: LokiVisualQueryOperationCategory.Aggregations,
renderer: getAggregationByRenderer(name),
paramChangedHandler: getLastLabelRemovedHandler(name),
explainHandler: getAggregationExplainer(name, 'by'),
addOperationHandler: defaultAddOperationHandler,
hideFromList: true,
...overrides,
},
{
id: `__${name}_without`,
name: `${getLokiOperationDisplayName(name)} without`,
params: [
{
name: 'Label',
type: 'string',
restParam: true,
optional: true,
editor: LabelParamEditor,
},
],
defaultParams: [''],
alternativesKey: 'aggregations by',
category: LokiVisualQueryOperationCategory.Aggregations,
renderer: getAggregationWithoutRenderer(name),
paramChangedHandler: getLastLabelRemovedHandler(name),
explainHandler: getAggregationExplainer(name, 'without'),
addOperationHandler: defaultAddOperationHandler,
hideFromList: true,
...overrides,
},
];
return operations;
}
function getAggregationWithoutRenderer(aggregation: string) {
return function aggregationRenderer(
model: QueryBuilderOperation,
def: QueryBuilderOperationDefinition,
innerExpr: string
) {
return `${aggregation} without(${model.params.join(', ')}) (${innerExpr})`;
};
}
export function functionRendererLeft(
model: QueryBuilderOperation,
def: QueryBuilderOperationDefinition,
innerExpr: string
) {
const params = renderParams(model, def, innerExpr);
const str = model.id + '(';
if (innerExpr) {
params.push(innerExpr);
}
return str + params.join(', ') + ')';
}
function renderParams(model: QueryBuilderOperation, def: QueryBuilderOperationDefinition, innerExpr: string) {
return (model.params ?? []).map((value, index) => {
const paramDef = def.params[index];
if (paramDef.type === 'string') {
return '"' + value + '"';
}
return value;
});
}
function getAggregationByRenderer(aggregation: string) {
return function aggregationRenderer(
model: QueryBuilderOperation,
def: QueryBuilderOperationDefinition,
innerExpr: string
) {
return `${aggregation} by(${model.params.join(', ')}) (${innerExpr})`;
};
}
export function createAggregationOperationWithParam(
name: string,
paramsDef: { params: QueryBuilderOperationParamDef[]; defaultParams: QueryBuilderOperationParamValue[] },
overrides: Partial<QueryBuilderOperationDefinition> = {}
): QueryBuilderOperationDefinition[] {
const operations = createAggregationOperation(name, overrides);
operations[0].params.unshift(...paramsDef.params);
operations[1].params.unshift(...paramsDef.params);
operations[2].params.unshift(...paramsDef.params);
operations[0].defaultParams = paramsDef.defaultParams;
operations[1].defaultParams = [...paramsDef.defaultParams, ''];
operations[2].defaultParams = [...paramsDef.defaultParams, ''];
operations[1].renderer = getAggregationByRendererWithParameter(name);
operations[2].renderer = getAggregationByRendererWithParameter(name);
return operations;
}
function getAggregationByRendererWithParameter(aggregation: string) {
return function aggregationRenderer(
model: QueryBuilderOperation,
def: QueryBuilderOperationDefinition,
innerExpr: string
) {
const restParamIndex = def.params.findIndex((param) => param.restParam);
const params = model.params.slice(0, restParamIndex);
const restParams = model.params.slice(restParamIndex);
return `${aggregation} by(${restParams.join(', ')}) (${params
.map((param, idx) => (def.params[idx].type === 'string' ? `\"${param}\"` : param))
.join(', ')}, ${innerExpr})`;
};
}