Loki: Preserve pipeline stages in context query (#70472)

* add pipeline stages to context query

* add ui

* improve `Postion`

* add `processPipelineStagesToExpr` to logcontextprovider

* add ui toggle

* fix lokicontextui tests

* remove import

* contextually hide the toggle

* Update `SHOULD_INCLUDE_PIPELINE_OPERATIONS` name

Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>

* add setIncludePipelineOperations to false in revert

* add prepareExpression method

* remove unused method

* fix test and add `runContextQuery`

* set correct revert state

* let let be const

* remove argument

---------

Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>
This commit is contained in:
Sven Grossmann 2023-06-22 17:34:43 +02:00 committed by GitHub
parent 8079c1456f
commit d7337e4f9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 589 additions and 68 deletions

View File

@ -3,14 +3,26 @@ import { of } from 'rxjs';
import { DataQueryResponse, FieldType, LogRowContextQueryDirection, LogRowModel, createDataFrame } from '@grafana/data'; import { DataQueryResponse, FieldType, LogRowContextQueryDirection, LogRowModel, createDataFrame } from '@grafana/data';
import LokiLanguageProvider from './LanguageProvider'; import LokiLanguageProvider from './LanguageProvider';
import { LogContextProvider, LOKI_LOG_CONTEXT_PRESERVED_LABELS } from './LogContextProvider'; import {
LogContextProvider,
LOKI_LOG_CONTEXT_PRESERVED_LABELS,
SHOULD_INCLUDE_PIPELINE_OPERATIONS,
} from './LogContextProvider';
import { createLokiDatasource } from './mocks'; import { createLokiDatasource } from './mocks';
import { LokiQuery } from './types'; import { LokiQuery } from './types';
jest.mock('app/core/store', () => { jest.mock('app/core/store', () => {
return { return {
get() { get(item: string) {
return window.localStorage.getItem(LOKI_LOG_CONTEXT_PRESERVED_LABELS); return window.localStorage.getItem(item);
},
getBool(key: string, defaultValue?: boolean) {
const item = window.localStorage.getItem(key);
if (item === null) {
return defaultValue;
} else {
return item === 'true';
}
}, },
}; };
}); });
@ -205,6 +217,143 @@ describe('LogContextProvider', () => {
expect(contextQuery.query.expr).toEqual(`{bar="baz"}`); expect(contextQuery.query.expr).toEqual(`{bar="baz"}`);
}); });
it('should not apply line_format if flag is not set by default', async () => {
logContextProvider.appliedContextFilters = [{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }];
const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
defaultLogRow,
10,
LogRowContextQueryDirection.Backward,
{
expr: '{bar="baz"} | logfmt | line_format = "foo"',
} as unknown as LokiQuery
);
expect(contextQuery.query.expr).toEqual(`{bar="baz"} | logfmt`);
});
it('should not apply line_format if flag is not set', async () => {
window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'false');
logContextProvider.appliedContextFilters = [{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }];
const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
defaultLogRow,
10,
LogRowContextQueryDirection.Backward,
{
expr: '{bar="baz"} | logfmt | line_format = "foo"',
} as unknown as LokiQuery
);
expect(contextQuery.query.expr).toEqual(`{bar="baz"} | logfmt`);
});
it('should apply line_format if flag is set', async () => {
window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'true');
logContextProvider.appliedContextFilters = [{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }];
const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
defaultLogRow,
10,
LogRowContextQueryDirection.Backward,
{
expr: '{bar="baz"} | logfmt | line_format = "foo"',
} as unknown as LokiQuery
);
expect(contextQuery.query.expr).toEqual(`{bar="baz"} | logfmt | line_format = "foo"`);
});
it('should not apply line filters if flag is set', async () => {
window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'true');
logContextProvider.appliedContextFilters = [{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }];
let contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
defaultLogRow,
10,
LogRowContextQueryDirection.Backward,
{
expr: '{bar="baz"} | logfmt | line_format = "foo" |= "bar"',
} as unknown as LokiQuery
);
expect(contextQuery.query.expr).toEqual(`{bar="baz"} | logfmt | line_format = "foo"`);
contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
defaultLogRow,
10,
LogRowContextQueryDirection.Backward,
{
expr: '{bar="baz"} | logfmt | line_format = "foo" |~ "bar"',
} as unknown as LokiQuery
);
expect(contextQuery.query.expr).toEqual(`{bar="baz"} | logfmt | line_format = "foo"`);
contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
defaultLogRow,
10,
LogRowContextQueryDirection.Backward,
{
expr: '{bar="baz"} | logfmt | line_format = "foo" !~ "bar"',
} as unknown as LokiQuery
);
expect(contextQuery.query.expr).toEqual(`{bar="baz"} | logfmt | line_format = "foo"`);
contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
defaultLogRow,
10,
LogRowContextQueryDirection.Backward,
{
expr: '{bar="baz"} | logfmt | line_format = "foo" != "bar"',
} as unknown as LokiQuery
);
expect(contextQuery.query.expr).toEqual(`{bar="baz"} | logfmt | line_format = "foo"`);
});
it('should not apply line filters if nested between two operations', async () => {
window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'true');
logContextProvider.appliedContextFilters = [{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }];
const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
defaultLogRow,
10,
LogRowContextQueryDirection.Backward,
{
expr: '{bar="baz"} | logfmt | line_format "foo" |= "bar" | label_format a="baz"',
} as unknown as LokiQuery
);
expect(contextQuery.query.expr).toEqual(`{bar="baz"} | logfmt | line_format "foo" | label_format a="baz"`);
});
it('should not apply label filters', async () => {
window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'true');
logContextProvider.appliedContextFilters = [{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }];
const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
defaultLogRow,
10,
LogRowContextQueryDirection.Backward,
{
expr: '{bar="baz"} | logfmt | line_format "foo" | bar > 1 | label_format a="baz"',
} as unknown as LokiQuery
);
expect(contextQuery.query.expr).toEqual(`{bar="baz"} | logfmt | line_format "foo" | label_format a="baz"`);
});
it('should not apply additional parsers', async () => {
window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'true');
logContextProvider.appliedContextFilters = [{ value: 'baz', enabled: true, fromParser: false, label: 'bar' }];
const contextQuery = await logContextProvider.prepareLogRowContextQueryTarget(
defaultLogRow,
10,
LogRowContextQueryDirection.Backward,
{
expr: '{bar="baz"} | logfmt | line_format "foo" | json | label_format a="baz"',
} as unknown as LokiQuery
);
expect(contextQuery.query.expr).toEqual(`{bar="baz"}`);
});
}); });
describe('getInitContextFiltersFromLabels', () => { describe('getInitContextFiltersFromLabels', () => {
@ -312,4 +461,38 @@ describe('LogContextProvider', () => {
}); });
}); });
}); });
describe('queryContainsValidPipelineStages', () => {
it('should return true if query contains a line_format stage', () => {
expect(
logContextProvider.queryContainsValidPipelineStages({ expr: '{foo="bar"} | line_format "foo"' } as LokiQuery)
).toBe(true);
});
it('should return true if query contains a label_format stage', () => {
expect(
logContextProvider.queryContainsValidPipelineStages({ expr: '{foo="bar"} | label_format a="foo"' } as LokiQuery)
).toBe(true);
});
it('should return false if query contains a parser', () => {
expect(logContextProvider.queryContainsValidPipelineStages({ expr: '{foo="bar"} | json' } as LokiQuery)).toBe(
false
);
});
it('should return false if query contains a line filter', () => {
expect(logContextProvider.queryContainsValidPipelineStages({ expr: '{foo="bar"} |= "test"' } as LokiQuery)).toBe(
false
);
});
it('should return true if query contains a line filter and a label_format', () => {
expect(
logContextProvider.queryContainsValidPipelineStages({
expr: '{foo="bar"} |= "test" | label_format a="foo"',
} as LokiQuery)
).toBe(true);
});
});
}); });

View File

@ -14,6 +14,7 @@ import {
LogRowContextQueryDirection, LogRowContextQueryDirection,
LogRowContextOptions, LogRowContextOptions,
} from '@grafana/data'; } from '@grafana/data';
import { LabelParser, LabelFilter, LineFilters, PipelineStage } from '@grafana/lezer-logql';
import { Labels } from '@grafana/schema'; import { Labels } from '@grafana/schema';
import { notifyApp } from 'app/core/actions'; import { notifyApp } from 'app/core/actions';
import { createSuccessNotification } from 'app/core/copy/appNotification'; import { createSuccessNotification } from 'app/core/copy/appNotification';
@ -24,11 +25,18 @@ import { LokiContextUi } from './components/LokiContextUi';
import { LokiDatasource, makeRequest, REF_ID_STARTER_LOG_ROW_CONTEXT } from './datasource'; import { LokiDatasource, makeRequest, REF_ID_STARTER_LOG_ROW_CONTEXT } from './datasource';
import { escapeLabelValueInExactSelector } from './languageUtils'; import { escapeLabelValueInExactSelector } from './languageUtils';
import { addLabelToQuery, addParserToQuery } from './modifyQuery'; import { addLabelToQuery, addParserToQuery } from './modifyQuery';
import { getParserFromQuery, getStreamSelectorsFromQuery, isQueryWithParser } from './queryUtils'; import {
getNodePositionsFromQuery,
getParserFromQuery,
getStreamSelectorsFromQuery,
isQueryWithParser,
} from './queryUtils';
import { sortDataFrameByTime, SortDirection } from './sortDataFrame'; import { sortDataFrameByTime, SortDirection } from './sortDataFrame';
import { ContextFilter, LokiQuery, LokiQueryDirection, LokiQueryType } from './types'; import { ContextFilter, LokiQuery, LokiQueryDirection, LokiQueryType } from './types';
export const LOKI_LOG_CONTEXT_PRESERVED_LABELS = 'lokiLogContextPreservedLabels'; export const LOKI_LOG_CONTEXT_PRESERVED_LABELS = 'lokiLogContextPreservedLabels';
export const SHOULD_INCLUDE_PIPELINE_OPERATIONS = 'lokiLogContextShouldIncludePipelineOperations';
export type PreservedLabels = { export type PreservedLabels = {
removedLabels: string[]; removedLabels: string[];
selectedExtractedLabels: string[]; selectedExtractedLabels: string[];
@ -109,7 +117,8 @@ export class LogContextProvider {
direction: LogRowContextQueryDirection, direction: LogRowContextQueryDirection,
origQuery?: LokiQuery origQuery?: LokiQuery
): Promise<{ query: LokiQuery; range: TimeRange }> { ): Promise<{ query: LokiQuery; range: TimeRange }> {
const expr = this.processContextFiltersToExpr(row, this.appliedContextFilters, origQuery); const expr = this.prepareExpression(this.appliedContextFilters, origQuery);
const contextTimeBuffer = 2 * 60 * 60 * 1000; // 2h buffer const contextTimeBuffer = 2 * 60 * 60 * 1000; // 2h buffer
const queryDirection = const queryDirection =
@ -180,10 +189,19 @@ export class LogContextProvider {
updateFilter, updateFilter,
onClose: this.onContextClose, onClose: this.onContextClose,
logContextProvider: this, logContextProvider: this,
runContextQuery,
}); });
} }
processContextFiltersToExpr = (row: LogRowModel, contextFilters: ContextFilter[], query: LokiQuery | undefined) => { prepareExpression(contextFilters: ContextFilter[], query: LokiQuery | undefined): string {
let preparedExpression = this.processContextFiltersToExpr(contextFilters, query);
if (store.getBool(SHOULD_INCLUDE_PIPELINE_OPERATIONS, false)) {
preparedExpression = this.processPipelineStagesToExpr(preparedExpression, query);
}
return preparedExpression;
}
processContextFiltersToExpr = (contextFilters: ContextFilter[], query: LokiQuery | undefined): string => {
const labelFilters = contextFilters const labelFilters = contextFilters
.map((filter) => { .map((filter) => {
if (!filter.fromParser && filter.enabled) { if (!filter.fromParser && filter.enabled) {
@ -216,6 +234,51 @@ export class LogContextProvider {
return expr; return expr;
}; };
processPipelineStagesToExpr = (currentExpr: string, query: LokiQuery | undefined): string => {
let newExpr = currentExpr;
const origExpr = query?.expr ?? '';
if (isQueryWithParser(origExpr).parserCount > 1) {
return newExpr;
}
const allNodePositions = getNodePositionsFromQuery(origExpr, [
PipelineStage,
LabelParser,
LineFilters,
LabelFilter,
]);
const pipelineStagePositions = allNodePositions.filter((position) => position.type?.id === PipelineStage);
const otherNodePositions = allNodePositions.filter((position) => position.type?.id !== PipelineStage);
for (const pipelineStagePosition of pipelineStagePositions) {
// we don't process pipeline stages that contain label parsers, line filters or label filters
if (otherNodePositions.some((position) => pipelineStagePosition.contains(position))) {
continue;
}
newExpr += ` ${pipelineStagePosition.getExpression(origExpr)}`;
}
return newExpr;
};
queryContainsValidPipelineStages = (query: LokiQuery | undefined): boolean => {
const origExpr = query?.expr ?? '';
const allNodePositions = getNodePositionsFromQuery(origExpr, [
PipelineStage,
LabelParser,
LineFilters,
LabelFilter,
]);
const pipelineStagePositions = allNodePositions.filter((position) => position.type?.id === PipelineStage);
const otherNodePositions = allNodePositions.filter((position) => position.type?.id !== PipelineStage);
return pipelineStagePositions.some((pipelineStagePosition) =>
otherNodePositions.every((position) => pipelineStagePosition.contains(position) === false)
);
};
getInitContextFilters = async (labels: Labels, query?: LokiQuery) => { getInitContextFilters = async (labels: Labels, query?: LokiQuery) => {
if (!query || isEmpty(labels)) { if (!query || isEmpty(labels)) {
return []; return [];

View File

@ -5,10 +5,10 @@ import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
import { LogRowModel } from '@grafana/data'; import { LogRowModel } from '@grafana/data';
import { LogContextProvider } from '../LogContextProvider'; import { LogContextProvider, SHOULD_INCLUDE_PIPELINE_OPERATIONS } from '../LogContextProvider';
import { ContextFilter, LokiQuery } from '../types'; import { ContextFilter, LokiQuery } from '../types';
import { LokiContextUi, LokiContextUiProps } from './LokiContextUi'; import { IS_LOKI_LOG_CONTEXT_UI_OPEN, LokiContextUi, LokiContextUiProps } from './LokiContextUi';
// we have to mock out reportInteraction, otherwise it crashes the test. // we have to mock out reportInteraction, otherwise it crashes the test.
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
@ -19,8 +19,13 @@ jest.mock('@grafana/runtime', () => ({
jest.mock('app/core/store', () => { jest.mock('app/core/store', () => {
return { return {
set() {}, set() {},
getBool() { getBool(key: string, defaultValue?: boolean) {
return true; const item = window.localStorage.getItem(key);
if (item === null) {
return defaultValue;
} else {
return item === 'true';
}
}, },
delete() {}, delete() {},
}; };
@ -28,7 +33,7 @@ jest.mock('app/core/store', () => {
const setupProps = (): LokiContextUiProps => { const setupProps = (): LokiContextUiProps => {
const defaults: LokiContextUiProps = { const defaults: LokiContextUiProps = {
logContextProvider: mockLogContextProvider as unknown as LogContextProvider, logContextProvider: Object.assign({}, mockLogContextProvider) as unknown as LogContextProvider,
updateFilter: jest.fn(), updateFilter: jest.fn(),
row: { row: {
entry: 'WARN test 1.23 on [xxx]', entry: 'WARN test 1.23 on [xxx]',
@ -42,6 +47,7 @@ const setupProps = (): LokiContextUiProps => {
expr: '{label1="value1"} | logfmt', expr: '{label1="value1"} | logfmt',
refId: 'A', refId: 'A',
}, },
runContextQuery: jest.fn(),
}; };
return defaults; return defaults;
@ -55,13 +61,24 @@ const mockLogContextProvider = {
]) ])
), ),
processContextFiltersToExpr: jest.fn().mockImplementation( processContextFiltersToExpr: jest.fn().mockImplementation(
(row: LogRowModel, contextFilters: ContextFilter[], query: LokiQuery | undefined) => (contextFilters: ContextFilter[], query: LokiQuery | undefined) =>
`{${contextFilters `{${contextFilters
.filter((filter) => filter.enabled) .filter((filter) => filter.enabled)
.map((filter) => `${filter.label}="${filter.value}"`) .map((filter) => `${filter.label}="${filter.value}"`)
.join('` ')}}` .join('` ')}}`
), ),
processPipelineStagesToExpr: jest
.fn()
.mockImplementation((currentExpr: string, query: LokiQuery | undefined) => `${currentExpr} | newOperation`),
getLogRowContext: jest.fn(), getLogRowContext: jest.fn(),
queryContainsValidPipelineStages: jest.fn().mockReturnValue(true),
prepareExpression: jest.fn().mockImplementation(
(contextFilters: ContextFilter[], query: LokiQuery | undefined) =>
`{${contextFilters
.filter((filter) => filter.enabled)
.map((filter) => `${filter.label}="${filter.value}"`)
.join('` ')}}`
),
}; };
describe('LokiContextUi', () => { describe('LokiContextUi', () => {
@ -80,6 +97,15 @@ describe('LokiContextUi', () => {
global = savedGlobal; global = savedGlobal;
}); });
beforeEach(() => {
window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'true');
window.localStorage.setItem(IS_LOKI_LOG_CONTEXT_UI_OPEN, 'true');
});
afterEach(() => {
window.localStorage.clear();
});
it('renders and shows executed query text', async () => { it('renders and shows executed query text', async () => {
const props = setupProps(); const props = setupProps();
render(<LokiContextUi {...props} />); render(<LokiContextUi {...props} />);
@ -193,6 +219,72 @@ describe('LokiContextUi', () => {
}); });
}); });
it('renders pipeline operations switch as enabled when saved in localstorage', async () => {
const props = setupProps();
const newProps = {
...props,
origQuery: {
expr: '{label1="value1"} | logfmt',
refId: 'A',
},
};
window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'true');
render(<LokiContextUi {...newProps} />);
await waitFor(() => {
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(true);
});
});
it('renders pipeline operations switch as disabled when saved in localstorage', async () => {
const props = setupProps();
const newProps = {
...props,
origQuery: {
expr: '{label1="value1"} | logfmt',
refId: 'A',
},
};
window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'false');
render(<LokiContextUi {...newProps} />);
await waitFor(() => {
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(false);
});
});
it('renders pipeline operations switch if query contains valid pipeline stages', async () => {
const props = setupProps();
(props.logContextProvider.queryContainsValidPipelineStages as jest.Mock).mockReturnValue(true);
const newProps = {
...props,
origQuery: {
expr: '{label1="value1"} | logfmt',
refId: 'A',
},
};
window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'true');
render(<LokiContextUi {...newProps} />);
await waitFor(() => {
expect(screen.getByRole('checkbox')).toBeInTheDocument();
});
});
it('does not render pipeline operations switch if query does not contain valid pipeline stages', async () => {
const props = setupProps();
(props.logContextProvider.queryContainsValidPipelineStages as jest.Mock).mockReturnValue(false);
const newProps = {
...props,
origQuery: {
expr: '{label1="value1"} | logfmt',
refId: 'A',
},
};
window.localStorage.setItem(SHOULD_INCLUDE_PIPELINE_OPERATIONS, 'true');
render(<LokiContextUi {...newProps} />);
await waitFor(() => {
expect(screen.queryByRole('checkbox')).toBeNull();
});
});
it('does not show parsed labels section if origQuery has 2 parsers', async () => { it('does not show parsed labels section if origQuery has 2 parsers', async () => {
const props = setupProps(); const props = setupProps();
const newProps = { const newProps = {

View File

@ -2,13 +2,31 @@ import { css } from '@emotion/css';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAsync } from 'react-use'; import { useAsync } from 'react-use';
import { GrafanaTheme2, LogRowModel, SelectableValue } from '@grafana/data'; import { GrafanaTheme2, LogRowModel, renderMarkdown, SelectableValue } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime'; import { reportInteraction } from '@grafana/runtime';
import { Button, Collapse, Icon, Label, MultiSelect, Spinner, Tooltip, useStyles2 } from '@grafana/ui'; import {
Button,
Collapse,
Icon,
InlineField,
InlineFieldRow,
InlineSwitch,
Label,
MultiSelect,
RenderUserContentAsHTML,
Spinner,
Tooltip,
useStyles2,
} from '@grafana/ui';
import store from 'app/core/store'; import store from 'app/core/store';
import { RawQuery } from '../../prometheus/querybuilder/shared/RawQuery'; import { RawQuery } from '../../prometheus/querybuilder/shared/RawQuery';
import { LogContextProvider, LOKI_LOG_CONTEXT_PRESERVED_LABELS, PreservedLabels } from '../LogContextProvider'; import {
LogContextProvider,
LOKI_LOG_CONTEXT_PRESERVED_LABELS,
PreservedLabels,
SHOULD_INCLUDE_PIPELINE_OPERATIONS,
} from '../LogContextProvider';
import { escapeLabelValueInSelector } from '../languageUtils'; import { escapeLabelValueInSelector } from '../languageUtils';
import { isQueryWithParser } from '../queryUtils'; import { isQueryWithParser } from '../queryUtils';
import { lokiGrammar } from '../syntax'; import { lokiGrammar } from '../syntax';
@ -20,6 +38,7 @@ export interface LokiContextUiProps {
updateFilter: (value: ContextFilter[]) => void; updateFilter: (value: ContextFilter[]) => void;
onClose: () => void; onClose: () => void;
origQuery?: LokiQuery; origQuery?: LokiQuery;
runContextQuery?: () => void;
} }
function getStyles(theme: GrafanaTheme2) { function getStyles(theme: GrafanaTheme2) {
@ -74,13 +93,22 @@ function getStyles(theme: GrafanaTheme2) {
right: ${theme.spacing(1)}; right: ${theme.spacing(1)};
z-index: ${theme.zIndex.navbarFixed}; z-index: ${theme.zIndex.navbarFixed};
`, `,
operationsToggle: css`
margin: ${theme.spacing(1)} 0 ${theme.spacing(-1)} 0;
& > div {
margin: 0;
& > label {
padding: 0;
}
}
`,
}; };
} }
const IS_LOKI_LOG_CONTEXT_UI_OPEN = 'isLogContextQueryUiOpen'; export const IS_LOKI_LOG_CONTEXT_UI_OPEN = 'isLogContextQueryUiOpen';
export function LokiContextUi(props: LokiContextUiProps) { export function LokiContextUi(props: LokiContextUiProps) {
const { row, logContextProvider, updateFilter, onClose, origQuery } = props; const { row, logContextProvider, updateFilter, onClose, origQuery, runContextQuery } = props;
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const [contextFilters, setContextFilters] = useState<ContextFilter[]>([]); const [contextFilters, setContextFilters] = useState<ContextFilter[]>([]);
@ -88,19 +116,27 @@ export function LokiContextUi(props: LokiContextUiProps) {
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(store.getBool(IS_LOKI_LOG_CONTEXT_UI_OPEN, false)); const [isOpen, setIsOpen] = useState(store.getBool(IS_LOKI_LOG_CONTEXT_UI_OPEN, false));
const [includePipelineOperations, setIncludePipelineOperations] = useState(
store.getBool(SHOULD_INCLUDE_PIPELINE_OPERATIONS, false)
);
const timerHandle = React.useRef<number>(); const timerHandle = React.useRef<number>();
const previousInitialized = React.useRef<boolean>(false); const previousInitialized = React.useRef<boolean>(false);
const previousContextFilters = React.useRef<ContextFilter[]>([]); const previousContextFilters = React.useRef<ContextFilter[]>([]);
const isInitialQuery = useMemo(() => { const isInitialState = useMemo(() => {
// Initial query has all regular labels enabled and all parsed labels disabled // Initial query has all regular labels enabled and all parsed labels disabled
if (initialized && contextFilters.some((filter) => filter.fromParser === filter.enabled)) { if (initialized && contextFilters.some((filter) => filter.fromParser === filter.enabled)) {
return false; return false;
} }
// if we include pipeline operations, we also want to enable the revert button
if (includePipelineOperations && logContextProvider.queryContainsValidPipelineStages(origQuery)) {
return false;
}
return true; return true;
}, [contextFilters, initialized]); }, [contextFilters, includePipelineOperations, initialized, logContextProvider, origQuery]);
useEffect(() => { useEffect(() => {
if (!initialized) { if (!initialized) {
@ -200,6 +236,10 @@ export function LokiContextUi(props: LokiContextUiProps) {
// Currently we support adding of parser and showing parsed labels only if there is 1 parser // Currently we support adding of parser and showing parsed labels only if there is 1 parser
const showParsedLabels = origQuery && isQueryWithParser(origQuery.expr).parserCount === 1 && parsedLabels.length > 0; const showParsedLabels = origQuery && isQueryWithParser(origQuery.expr).parserCount === 1 && parsedLabels.length > 0;
let queryExpr = logContextProvider.prepareExpression(
contextFilters.filter(({ enabled }) => enabled),
origQuery
);
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<Tooltip content={'Revert to initial log context query.'}> <Tooltip content={'Revert to initial log context query.'}>
@ -208,7 +248,7 @@ export function LokiContextUi(props: LokiContextUiProps) {
data-testid="revert-button" data-testid="revert-button"
icon="history-alt" icon="history-alt"
variant="secondary" variant="secondary"
disabled={isInitialQuery} disabled={isInitialState}
onClick={(e) => { onClick={(e) => {
reportInteraction('grafana_explore_logs_loki_log_context_reverted', { reportInteraction('grafana_explore_logs_loki_log_context_reverted', {
logRowUid: row.uid, logRowUid: row.uid,
@ -222,6 +262,8 @@ export function LokiContextUi(props: LokiContextUiProps) {
}); });
// We are removing the preserved labels from local storage so we can preselect the labels in the UI // We are removing the preserved labels from local storage so we can preselect the labels in the UI
store.delete(LOKI_LOG_CONTEXT_PRESERVED_LABELS); store.delete(LOKI_LOG_CONTEXT_PRESERVED_LABELS);
store.delete(SHOULD_INCLUDE_PIPELINE_OPERATIONS);
setIncludePipelineOperations(false);
}} }}
/> />
</div> </div>
@ -242,15 +284,7 @@ export function LokiContextUi(props: LokiContextUiProps) {
<div className={styles.rawQueryContainer}> <div className={styles.rawQueryContainer}>
{initialized ? ( {initialized ? (
<> <>
<RawQuery <RawQuery lang={{ grammar: lokiGrammar, name: 'loki' }} query={queryExpr} className={styles.rawQuery} />
lang={{ grammar: lokiGrammar, name: 'loki' }}
query={logContextProvider.processContextFiltersToExpr(
row,
contextFilters.filter(({ enabled }) => enabled),
origQuery
)}
className={styles.rawQuery}
/>
<Tooltip content="The initial log context query is created from all labels defining the stream for the selected log line. Use the editor below to customize the log context query."> <Tooltip content="The initial log context query is created from all labels defining the stream for the selected log line. Use the editor below to customize the log context query.">
<Icon name="info-circle" size="sm" className={styles.queryDescription} /> <Icon name="info-circle" size="sm" className={styles.queryDescription} />
</Tooltip> </Tooltip>
@ -345,6 +379,37 @@ export function LokiContextUi(props: LokiContextUiProps) {
/> />
</> </>
)} )}
{logContextProvider.queryContainsValidPipelineStages(origQuery) && (
<InlineFieldRow className={styles.operationsToggle}>
<InlineField
label="Include LogQL pipeline operations"
tooltip={
<RenderUserContentAsHTML
content={renderMarkdown(
"This will include LogQL operations such as `line_format` or `label_format`. It won't include line or label filter operations."
)}
/>
}
>
<InlineSwitch
value={includePipelineOperations}
showLabel={true}
transparent={true}
onChange={(e) => {
reportInteraction('grafana_explore_logs_loki_log_context_pipeline_toggled', {
logRowUid: row.uid,
action: e.currentTarget.checked ? 'enable' : 'disable',
});
store.set(SHOULD_INCLUDE_PIPELINE_OPERATIONS, e.currentTarget.checked);
setIncludePipelineOperations(e.currentTarget.checked);
if (runContextQuery) {
runContextQuery();
}
}}
/>
</InlineField>
</InlineFieldRow>
)}
</div> </div>
</Collapse> </Collapse>
</div> </div>

View File

@ -1,8 +1,11 @@
import { SyntaxNode } from '@lezer/common';
import { import {
addLabelFormatToQuery, addLabelFormatToQuery,
addLabelToQuery, addLabelToQuery,
addNoPipelineErrorToQuery, addNoPipelineErrorToQuery,
addParserToQuery, addParserToQuery,
NodePosition,
removeCommentsFromQuery, removeCommentsFromQuery,
} from './modifyQuery'; } from './modifyQuery';
@ -187,3 +190,59 @@ describe('removeCommentsFromQuery', () => {
expect(removeCommentsFromQuery(query)).toBe(expectedResult); expect(removeCommentsFromQuery(query)).toBe(expectedResult);
}); });
}); });
describe('NodePosition', () => {
describe('contains', () => {
it('should return true if the position is contained within the current position', () => {
const position = new NodePosition(5, 10);
const containedPosition = new NodePosition(6, 9);
const result = position.contains(containedPosition);
expect(result).toBe(true);
});
it('should return false if the position is not contained within the current position', () => {
const position = new NodePosition(5, 10);
const outsidePosition = new NodePosition(11, 15);
const result = position.contains(outsidePosition);
expect(result).toBe(false);
});
it('should return true if the position is the same as the current position', () => {
const position = new NodePosition(5, 10);
const samePosition = new NodePosition(5, 10);
const result = position.contains(samePosition);
expect(result).toBe(true);
});
});
describe('getExpression', () => {
it('should return the substring of the query within the given position', () => {
const position = new NodePosition(7, 12);
const query = 'Hello, world!';
const result = position.getExpression(query);
expect(result).toBe('world');
});
it('should return an empty string if the position is out of range', () => {
const position = new NodePosition(15, 20);
const query = 'Hello, world!';
const result = position.getExpression(query);
expect(result).toBe('');
});
});
describe('fromNode', () => {
it('should create a new NodePosition instance from a SyntaxNode', () => {
const syntaxNode = {
from: 5,
to: 10,
type: 'identifier',
} as unknown as SyntaxNode;
const result = NodePosition.fromNode(syntaxNode);
expect(result).toBeInstanceOf(NodePosition);
expect(result.from).toBe(5);
expect(result.to).toBe(10);
expect(result.type).toBe('identifier');
});
});
});

View File

@ -1,4 +1,4 @@
import { SyntaxNode } from '@lezer/common'; import { NodeType, SyntaxNode } from '@lezer/common';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { import {
@ -22,7 +22,29 @@ import { unescapeLabelValue } from './languageUtils';
import { LokiQueryModeller } from './querybuilder/LokiQueryModeller'; import { LokiQueryModeller } from './querybuilder/LokiQueryModeller';
import { buildVisualQueryFromString } from './querybuilder/parsing'; import { buildVisualQueryFromString } from './querybuilder/parsing';
export type Position = { from: number; to: number }; export class NodePosition {
from: number;
to: number;
type?: NodeType;
constructor(from: number, to: number, type?: NodeType) {
this.from = from;
this.to = to;
this.type = type;
}
static fromNode(node: SyntaxNode): NodePosition {
return new NodePosition(node.from, node.to, node.type);
}
contains(position: NodePosition): boolean {
return this.from <= position.from && this.to >= position.to;
}
getExpression(query: string): string {
return query.substring(this.from, this.to);
}
}
/** /**
* Adds label filter to existing query. Useful for query modification for example for ad hoc filters. * Adds label filter to existing query. Useful for query modification for example for ad hoc filters.
* *
@ -139,13 +161,13 @@ export function removeCommentsFromQuery(query: string): string {
* selector. * selector.
* @param query * @param query
*/ */
export function getStreamSelectorPositions(query: string): Position[] { export function getStreamSelectorPositions(query: string): NodePosition[] {
const tree = parser.parse(query); const tree = parser.parse(query);
const positions: Position[] = []; const positions: NodePosition[] = [];
tree.iterate({ tree.iterate({
enter: ({ type, from, to }): false | void => { enter: ({ type, from, to }): false | void => {
if (type.id === Selector) { if (type.id === Selector) {
positions.push({ from, to }); positions.push(new NodePosition(from, to, type));
return false; return false;
} }
}, },
@ -153,9 +175,9 @@ export function getStreamSelectorPositions(query: string): Position[] {
return positions; return positions;
} }
function getMatcherInStreamPositions(query: string): Position[] { function getMatcherInStreamPositions(query: string): NodePosition[] {
const tree = parser.parse(query); const tree = parser.parse(query);
const positions: Position[] = []; const positions: NodePosition[] = [];
tree.iterate({ tree.iterate({
enter: ({ node }): false | void => { enter: ({ node }): false | void => {
if (node.type.id === Selector) { if (node.type.id === Selector) {
@ -170,13 +192,13 @@ function getMatcherInStreamPositions(query: string): Position[] {
* Parse the string and get all LabelParser positions in the query. * Parse the string and get all LabelParser positions in the query.
* @param query * @param query
*/ */
export function getParserPositions(query: string): Position[] { export function getParserPositions(query: string): NodePosition[] {
const tree = parser.parse(query); const tree = parser.parse(query);
const positions: Position[] = []; const positions: NodePosition[] = [];
tree.iterate({ tree.iterate({
enter: ({ type, from, to }): false | void => { enter: ({ type, from, to }): false | void => {
if (type.id === LabelParser || type.id === JsonExpressionParser) { if (type.id === LabelParser || type.id === JsonExpressionParser) {
positions.push({ from, to }); positions.push(new NodePosition(from, to, type));
return false; return false;
} }
}, },
@ -188,13 +210,13 @@ export function getParserPositions(query: string): Position[] {
* Parse the string and get all LabelFilter positions in the query. * Parse the string and get all LabelFilter positions in the query.
* @param query * @param query
*/ */
export function getLabelFilterPositions(query: string): Position[] { export function getLabelFilterPositions(query: string): NodePosition[] {
const tree = parser.parse(query); const tree = parser.parse(query);
const positions: Position[] = []; const positions: NodePosition[] = [];
tree.iterate({ tree.iterate({
enter: ({ type, from, to }): false | void => { enter: ({ type, from, to }): false | void => {
if (type.id === LabelFilter) { if (type.id === LabelFilter) {
positions.push({ from, to }); positions.push(new NodePosition(from, to, type));
return false; return false;
} }
}, },
@ -206,13 +228,13 @@ export function getLabelFilterPositions(query: string): Position[] {
* Parse the string and get all Line filter positions in the query. * Parse the string and get all Line filter positions in the query.
* @param query * @param query
*/ */
function getLineFiltersPositions(query: string): Position[] { function getLineFiltersPositions(query: string): NodePosition[] {
const tree = parser.parse(query); const tree = parser.parse(query);
const positions: Position[] = []; const positions: NodePosition[] = [];
tree.iterate({ tree.iterate({
enter: ({ type, node }): false | void => { enter: ({ type, from, to }): false | void => {
if (type.id === LineFilters) { if (type.id === LineFilters) {
positions.push({ from: node.from, to: node.to }); positions.push(new NodePosition(from, to, type));
return false; return false;
} }
}, },
@ -224,13 +246,13 @@ function getLineFiltersPositions(query: string): Position[] {
* Parse the string and get all Log query positions in the query. * Parse the string and get all Log query positions in the query.
* @param query * @param query
*/ */
function getLogQueryPositions(query: string): Position[] { function getLogQueryPositions(query: string): NodePosition[] {
const tree = parser.parse(query); const tree = parser.parse(query);
const positions: Position[] = []; const positions: NodePosition[] = [];
tree.iterate({ tree.iterate({
enter: ({ type, from, to, node }): false | void => { enter: ({ type, from, to, node }): false | void => {
if (type.id === LogExpr) { if (type.id === LogExpr) {
positions.push({ from, to }); positions.push(new NodePosition(from, to, type));
return false; return false;
} }
@ -238,25 +260,25 @@ function getLogQueryPositions(query: string): Position[] {
if (type.id === LogRangeExpr) { if (type.id === LogRangeExpr) {
// Unfortunately, LogRangeExpr includes both log and non-log (e.g. Duration/Range/...) parts of query. // Unfortunately, LogRangeExpr includes both log and non-log (e.g. Duration/Range/...) parts of query.
// We get position of all log-parts within LogRangeExpr: Selector, PipelineExpr and UnwrapExpr. // We get position of all log-parts within LogRangeExpr: Selector, PipelineExpr and UnwrapExpr.
const logPartsPositions: Position[] = []; const logPartsPositions: NodePosition[] = [];
const selector = node.getChild(Selector); const selector = node.getChild(Selector);
if (selector) { if (selector) {
logPartsPositions.push({ from: selector.from, to: selector.to }); logPartsPositions.push(NodePosition.fromNode(selector));
} }
const pipeline = node.getChild(PipelineExpr); const pipeline = node.getChild(PipelineExpr);
if (pipeline) { if (pipeline) {
logPartsPositions.push({ from: pipeline.from, to: pipeline.to }); logPartsPositions.push(NodePosition.fromNode(pipeline));
} }
const unwrap = node.getChild(UnwrapExpr); const unwrap = node.getChild(UnwrapExpr);
if (unwrap) { if (unwrap) {
logPartsPositions.push({ from: unwrap.from, to: unwrap.to }); logPartsPositions.push(NodePosition.fromNode(unwrap));
} }
// We sort them and then pick "from" from first position and "to" from last position. // We sort them and then pick "from" from first position and "to" from last position.
const sorted = sortBy(logPartsPositions, (position) => position.to); const sorted = sortBy(logPartsPositions, (position) => position.to);
positions.push({ from: sorted[0].from, to: sorted[sorted.length - 1].to }); positions.push(new NodePosition(sorted[0].from, sorted[sorted.length - 1].to));
return false; return false;
} }
}, },
@ -277,7 +299,7 @@ export function toLabelFilter(key: string, value: string, operator: string): Que
*/ */
function addFilterToStreamSelector( function addFilterToStreamSelector(
query: string, query: string,
vectorSelectorPositions: Position[], vectorSelectorPositions: NodePosition[],
filter: QueryBuilderLabelFilter filter: QueryBuilderLabelFilter
): string { ): string {
const modeller = new LokiQueryModeller(); const modeller = new LokiQueryModeller();
@ -313,7 +335,7 @@ function addFilterToStreamSelector(
*/ */
export function addFilterAsLabelFilter( export function addFilterAsLabelFilter(
query: string, query: string,
positionsToAddAfter: Position[], positionsToAddAfter: NodePosition[],
filter: QueryBuilderLabelFilter filter: QueryBuilderLabelFilter
): string { ): string {
let newQuery = ''; let newQuery = '';
@ -349,7 +371,7 @@ export function addFilterAsLabelFilter(
* @param queryPartPositions * @param queryPartPositions
* @param parser * @param parser
*/ */
function addParser(query: string, queryPartPositions: Position[], parser: string): string { function addParser(query: string, queryPartPositions: NodePosition[], parser: string): string {
let newQuery = ''; let newQuery = '';
let prev = 0; let prev = 0;
@ -376,7 +398,7 @@ function addParser(query: string, queryPartPositions: Position[], parser: string
*/ */
function addLabelFormat( function addLabelFormat(
query: string, query: string,
logQueryPositions: Position[], logQueryPositions: NodePosition[],
labelFormat: { originalLabel: string; renameTo: string } labelFormat: { originalLabel: string; renameTo: string }
): string { ): string {
let newQuery = ''; let newQuery = '';
@ -405,13 +427,13 @@ export function addLineFilter(query: string): string {
return newQueryExpr; return newQueryExpr;
} }
function getLineCommentPositions(query: string): Position[] { function getLineCommentPositions(query: string): NodePosition[] {
const tree = parser.parse(query); const tree = parser.parse(query);
const positions: Position[] = []; const positions: NodePosition[] = [];
tree.iterate({ tree.iterate({
enter: ({ type, from, to }): false | void => { enter: ({ type, from, to }): false | void => {
if (type.id === LineComment) { if (type.id === LineComment) {
positions.push({ from, to }); positions.push(new NodePosition(from, to, type));
return false; return false;
} }
}, },
@ -432,16 +454,16 @@ function labelExists(labels: QueryBuilderLabelFilter[], filter: QueryBuilderLabe
* Return the last position based on "to" property * Return the last position based on "to" property
* @param positions * @param positions
*/ */
export function findLastPosition(positions: Position[]): Position { export function findLastPosition(positions: NodePosition[]): NodePosition {
return positions.reduce((prev, current) => (prev.to > current.to ? prev : current)); return positions.reduce((prev, current) => (prev.to > current.to ? prev : current));
} }
function getAllPositionsInNodeByType(query: string, node: SyntaxNode, type: number): Position[] { function getAllPositionsInNodeByType(query: string, node: SyntaxNode, type: number): NodePosition[] {
if (node.type.id === type) { if (node.type.id === type) {
return [{ from: node.from, to: node.to }]; return [NodePosition.fromNode(node)];
} }
const positions: Position[] = []; const positions: NodePosition[] = [];
let pos = 0; let pos = 0;
let child = node.childAfter(pos); let child = node.childAfter(pos);
while (child) { while (child) {

View File

@ -1,3 +1,5 @@
import { String } from '@grafana/lezer-logql';
import { import {
getHighlighterExpressionsFromQuery, getHighlighterExpressionsFromQuery,
getLokiQueryType, getLokiQueryType,
@ -14,6 +16,7 @@ import {
isQueryPipelineErrorFiltering, isQueryPipelineErrorFiltering,
getLogQueryFromMetricsQuery, getLogQueryFromMetricsQuery,
getNormalizedLokiQuery, getNormalizedLokiQuery,
getNodePositionsFromQuery,
} from './queryUtils'; } from './queryUtils';
import { LokiQuery, LokiQueryType } from './types'; import { LokiQuery, LokiQueryType } from './types';
@ -416,3 +419,24 @@ describe('getLogQueryFromMetricsQuery', () => {
).toBe('{label="$var"} | logfmt | __error__=``'); ).toBe('{label="$var"} | logfmt | __error__=``');
}); });
}); });
describe('getNodePositionsFromQuery', () => {
it('returns the right amount of positions without type', () => {
// LogQL, Expr, LogExpr, Selector, Matchers, Matcher, Identifier, Eq, String
expect(getNodePositionsFromQuery('{job="grafana"}').length).toBe(9);
});
it('returns the right position of a string in a stream selector', () => {
// LogQL, Expr, LogExpr, Selector, Matchers, Matcher, Identifier, Eq, String
const nodePositions = getNodePositionsFromQuery('{job="grafana"}', [String]);
expect(nodePositions.length).toBe(1);
expect(nodePositions[0].from).toBe(5);
expect(nodePositions[0].to).toBe(14);
});
it('returns an empty array with a wrong expr', () => {
// LogQL, Expr, LogExpr, Selector, Matchers, Matcher, Identifier, Eq, String
const nodePositions = getNodePositionsFromQuery('not loql', [String]);
expect(nodePositions.length).toBe(0);
});
});

View File

@ -24,7 +24,7 @@ import { DataQuery } from '@grafana/schema';
import { ErrorId } from '../prometheus/querybuilder/shared/parsingUtils'; import { ErrorId } from '../prometheus/querybuilder/shared/parsingUtils';
import { getStreamSelectorPositions } from './modifyQuery'; import { getStreamSelectorPositions, NodePosition } from './modifyQuery';
import { LokiQuery, LokiQueryType } from './types'; import { LokiQuery, LokiQueryType } from './types';
export function formatQuery(selector: string | undefined): string { export function formatQuery(selector: string | undefined): string {
@ -145,12 +145,12 @@ export function isQueryWithNode(query: string, nodeType: number): boolean {
return isQueryWithNode; return isQueryWithNode;
} }
export function getNodesFromQuery(query: string, nodeTypes: number[]): SyntaxNode[] { export function getNodesFromQuery(query: string, nodeTypes?: number[]): SyntaxNode[] {
const nodes: SyntaxNode[] = []; const nodes: SyntaxNode[] = [];
const tree = parser.parse(query); const tree = parser.parse(query);
tree.iterate({ tree.iterate({
enter: (node): false | void => { enter: (node): false | void => {
if (nodeTypes.includes(node.type.id)) { if (nodeTypes === undefined || nodeTypes.includes(node.type.id)) {
nodes.push(node.node); nodes.push(node.node);
} }
}, },
@ -158,6 +158,19 @@ export function getNodesFromQuery(query: string, nodeTypes: number[]): SyntaxNod
return nodes; return nodes;
} }
export function getNodePositionsFromQuery(query: string, nodeTypes?: number[]): NodePosition[] {
const positions: NodePosition[] = [];
const tree = parser.parse(query);
tree.iterate({
enter: (node): false | void => {
if (nodeTypes === undefined || nodeTypes.includes(node.type.id)) {
positions.push(NodePosition.fromNode(node.node));
}
},
});
return positions;
}
export function getNodeFromQuery(query: string, nodeType: number): SyntaxNode | undefined { export function getNodeFromQuery(query: string, nodeType: number): SyntaxNode | undefined {
const nodes = getNodesFromQuery(query, [nodeType]); const nodes = getNodesFromQuery(query, [nodeType]);
return nodes.length > 0 ? nodes[0] : undefined; return nodes.length > 0 ? nodes[0] : undefined;