Loki: Use label/<name>/values API instead of series API for label values discovery (#83044)

* Loki: Add label values API selector to setting and use label_values API when selected

* remove CHANGELOG change

* add docs

* Support Loki 2.7+ only, so unconditionally use /labels API

* Correct doc for fetchLabels and fetchLabelValues functions

* Fixes after merge

* Do not encode query parameter twice

* return getLabelKeys in Completion Provider

* docs

* Add test for LabelParamEditor

* Update public/app/plugins/datasource/loki/LogContextProvider.test.ts

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

* toHaveBeenCalledWith

---------

Co-authored-by: Matias Chomicki <matyax@gmail.com>
Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>
pull/87336/head
Yuri Kotov 1 year ago committed by GitHub
parent 9c254c7e1e
commit 33170c4d07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 106
      public/app/plugins/datasource/loki/LanguageProvider.test.ts
  2. 33
      public/app/plugins/datasource/loki/LanguageProvider.ts
  3. 10
      public/app/plugins/datasource/loki/LogContextProvider.test.ts
  4. 5
      public/app/plugins/datasource/loki/LogContextProvider.ts
  5. 7
      public/app/plugins/datasource/loki/__mocks__/metadataRequest.ts
  6. 39
      public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.test.ts
  7. 27
      public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts
  8. 4
      public/app/plugins/datasource/loki/datasource.test.ts
  9. 14
      public/app/plugins/datasource/loki/datasource.ts
  10. 10
      public/app/plugins/datasource/loki/docs/app_plugin_developer_documentation.md
  11. 71
      public/app/plugins/datasource/loki/querybuilder/components/LabelParamEditor.test.tsx
  12. 4
      public/app/plugins/datasource/loki/querybuilder/components/LabelParamEditor.tsx
  13. 44
      public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.test.tsx
  14. 23
      public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilder.tsx
  15. 3
      public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderContainer.test.tsx
  16. 2
      public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.test.tsx

@ -151,7 +151,7 @@ describe('Language completion provider', () => {
});
});
describe('label values', () => {
describe('fetchLabelValues', () => {
it('should fetch label values if not cached', async () => {
const datasource = setup({ testkey: ['label1_val1', 'label1_val2'], label2: [] });
const provider = await getLanguageProvider(datasource);
@ -171,7 +171,7 @@ describe('Language completion provider', () => {
const labelValues = await provider.fetchLabelValues('testkey', { streamSelector: '{foo="bar"}' });
expect(requestSpy).toHaveBeenCalledWith('label/testkey/values', {
end: 1560163909000,
query: '%7Bfoo%3D%22bar%22%7D',
query: '{foo="bar"}',
start: 1560153109000,
});
expect(labelValues).toEqual(['label1_val1', 'label1_val2']);
@ -237,7 +237,7 @@ describe('Language completion provider', () => {
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(requestSpy).toHaveBeenCalledWith('label/testkey/values', {
end: 1560163909000,
query: '%7Bfoo%3D%22bar%22%7D',
query: '{foo="bar"}',
start: 1560153109000,
});
expect(labelValues).toEqual(['label1_val1', 'label1_val2']);
@ -263,68 +263,82 @@ describe('Language completion provider', () => {
await provider.fetchLabelValues('`\\"testkey', { streamSelector: '{foo="\\bar"}' });
expect(requestSpy).toHaveBeenCalledWith(expect.any(String), {
query: '%7Bfoo%3D%22%5Cbar%22%7D',
query: '{foo="\\bar"}',
start: expect.any(Number),
end: expect.any(Number),
});
});
});
});
describe('Request URL', () => {
it('should contain range params', async () => {
const datasourceWithLabels = setup({ other: [] });
const rangeParams = datasourceWithLabels.getTimeRangeParams(mockTimeRange);
const datasourceSpy = jest.spyOn(datasourceWithLabels, 'metadataRequest');
describe('fetchLabels', () => {
it('should return labels', async () => {
const datasourceWithLabels = setup({ other: [] });
const instance = new LanguageProvider(datasourceWithLabels);
instance.fetchLabels();
const expectedUrl = 'labels';
expect(datasourceSpy).toHaveBeenCalledWith(expectedUrl, rangeParams);
});
});
const instance = new LanguageProvider(datasourceWithLabels);
const labels = await instance.fetchLabels();
expect(labels).toEqual(['other']);
});
describe('fetchLabels', () => {
it('should return labels', async () => {
const datasourceWithLabels = setup({ other: [] });
it('should set labels', async () => {
const datasourceWithLabels = setup({ other: [] });
const instance = new LanguageProvider(datasourceWithLabels);
const labels = await instance.fetchLabels();
expect(labels).toEqual(['other']);
});
const instance = new LanguageProvider(datasourceWithLabels);
await instance.fetchLabels();
expect(instance.labelKeys).toEqual(['other']);
});
it('should set labels', async () => {
const datasourceWithLabels = setup({ other: [] });
it('should return empty array', async () => {
const datasourceWithLabels = setup({});
const instance = new LanguageProvider(datasourceWithLabels);
await instance.fetchLabels();
expect(instance.labelKeys).toEqual(['other']);
});
const instance = new LanguageProvider(datasourceWithLabels);
const labels = await instance.fetchLabels();
expect(labels).toEqual([]);
});
it('should return empty array', async () => {
const datasourceWithLabels = setup({});
it('should set empty array', async () => {
const datasourceWithLabels = setup({});
const instance = new LanguageProvider(datasourceWithLabels);
const labels = await instance.fetchLabels();
expect(labels).toEqual([]);
});
const instance = new LanguageProvider(datasourceWithLabels);
await instance.fetchLabels();
expect(instance.labelKeys).toEqual([]);
});
it('should set empty array', async () => {
const datasourceWithLabels = setup({});
it('should use time range param', async () => {
const datasourceWithLabels = setup({});
datasourceWithLabels.languageProvider.request = jest.fn();
const instance = new LanguageProvider(datasourceWithLabels);
await instance.fetchLabels();
expect(instance.labelKeys).toEqual([]);
const instance = new LanguageProvider(datasourceWithLabels);
instance.request = jest.fn();
await instance.fetchLabels({ timeRange: mockTimeRange });
expect(instance.request).toHaveBeenCalledWith('labels', datasourceWithLabels.getTimeRangeParams(mockTimeRange));
});
it('should use series endpoint for request with stream selector', async () => {
const datasourceWithLabels = setup({});
datasourceWithLabels.languageProvider.request = jest.fn();
const instance = new LanguageProvider(datasourceWithLabels);
instance.request = jest.fn();
await instance.fetchLabels({ streamSelector: '{foo="bar"}' });
expect(instance.request).toHaveBeenCalledWith('series', {
end: 1560163909000,
'match[]': '{foo="bar"}',
start: 1560153109000,
});
});
});
});
it('should use time range param', async () => {
const datasourceWithLabels = setup({});
datasourceWithLabels.languageProvider.request = jest.fn();
describe('Request URL', () => {
it('should contain range params', async () => {
const datasourceWithLabels = setup({ other: [] });
const rangeParams = datasourceWithLabels.getTimeRangeParams(mockTimeRange);
const datasourceSpy = jest.spyOn(datasourceWithLabels, 'metadataRequest');
const instance = new LanguageProvider(datasourceWithLabels);
instance.request = jest.fn();
await instance.fetchLabels({ timeRange: mockTimeRange });
expect(instance.request).toBeCalledWith('labels', datasourceWithLabels.getTimeRangeParams(mockTimeRange));
instance.fetchLabels();
const expectedUrl = 'labels';
expect(datasourceSpy).toHaveBeenCalledWith(expectedUrl, rangeParams);
});
});

@ -16,6 +16,7 @@ import {
import { ParserAndLabelKeysResult, LokiQuery, LokiQueryType, LabelType } from './types';
const NS_IN_MS = 1000000;
const EMPTY_SELECTOR = '{}';
export default class LokiLanguageProvider extends LanguageProvider {
labelKeys: string[];
@ -118,6 +119,28 @@ export default class LokiLanguageProvider extends LanguageProvider {
};
}
/**
* Fetch label keys using the best applicable endpoint.
*
* This asynchronous function returns all available label keys from the data source.
* It returns a promise that resolves to an array of strings containing the label keys.
*
* @param options - (Optional) An object containing additional options.
* @param options.streamSelector - (Optional) The stream selector to filter label keys. If not provided, all label keys are fetched.
* @param options.timeRange - (Optional) The time range for which you want to retrieve label keys. If not provided, the default time range is used.
* @returns A promise containing an array of label keys.
* @throws An error if the fetch operation fails.
*/
async fetchLabels(options?: { streamSelector?: string; timeRange?: TimeRange }): Promise<string[]> {
// If there is no stream selector - use /labels endpoint (https://github.com/grafana/loki/pull/11982)
if (!options || !options.streamSelector) {
return this.fetchLabelsByLabelsEndpoint(options);
} else {
const data = await this.fetchSeriesLabels(options.streamSelector, { timeRange: options.timeRange });
return Object.keys(data ?? {});
}
}
/**
* Fetch all label keys
* This asynchronous function returns all available label keys from the data source.
@ -128,7 +151,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
* @returns A promise containing an array of label keys.
* @throws An error if the fetch operation fails.
*/
async fetchLabels(options?: { timeRange?: TimeRange }): Promise<string[]> {
private async fetchLabelsByLabelsEndpoint(options?: { timeRange?: TimeRange }): Promise<string[]> {
const url = 'labels';
const range = options?.timeRange ?? this.getDefaultTimeRange();
const timeRange = this.datasource.getTimeRangeParams(range);
@ -229,9 +252,11 @@ export default class LokiLanguageProvider extends LanguageProvider {
options?: { streamSelector?: string; timeRange?: TimeRange }
): Promise<string[]> {
const label = encodeURIComponent(this.datasource.interpolateString(labelName));
const streamParam = options?.streamSelector
? encodeURIComponent(this.datasource.interpolateString(options.streamSelector))
: undefined;
// Loki doesn't allow empty streamSelector {}, so we should not send it.
const streamParam =
options?.streamSelector && options.streamSelector !== EMPTY_SELECTOR
? this.datasource.interpolateString(options.streamSelector)
: undefined;
const url = `label/${label}/values`;
const range = options?.timeRange ?? this.getDefaultTimeRange();

@ -20,7 +20,7 @@ import { LokiQuery } from './types';
const defaultLanguageProviderMock = {
start: jest.fn(),
fetchSeriesLabels: jest.fn(() => ({ bar: ['baz'], xyz: ['abc'] })),
fetchLabels: jest.fn(() => ['bar', 'xyz']),
getLabelKeys: jest.fn(() => ['bar', 'xyz']),
} as unknown as LokiLanguageProvider;
@ -425,14 +425,14 @@ describe('LogContextProvider', () => {
expect(filters).toEqual([]);
});
it('should call fetchSeriesLabels if parser', async () => {
it('should call fetchLabels with stream selector if parser', async () => {
await logContextProvider.getInitContextFilters(defaultLogRow, queryWithParser);
expect(defaultLanguageProviderMock.fetchSeriesLabels).toBeCalled();
expect(defaultLanguageProviderMock.fetchLabels).toBeCalledWith({ streamSelector: `{bar="baz"}` });
});
it('should call fetchSeriesLabels with given time range', async () => {
it('should call fetchLabels with given time range', async () => {
await logContextProvider.getInitContextFilters(defaultLogRow, queryWithParser, timeRange);
expect(defaultLanguageProviderMock.fetchSeriesLabels).toBeCalledWith(`{bar="baz"}`, { timeRange });
expect(defaultLanguageProviderMock.fetchLabels).toBeCalledWith({ streamSelector: `{bar="baz"}`, timeRange });
});
it('should call `languageProvider.start` if no parser with given time range', async () => {

@ -330,11 +330,10 @@ export class LogContextProvider {
await this.datasource.languageProvider.start(timeRange);
allLabels = this.datasource.languageProvider.getLabelKeys();
} else {
// If we have parser, we use fetchSeriesLabels to fetch actual labels for selected stream
// If we have parser, we use fetchLabels to fetch actual labels for selected stream
const stream = getStreamSelectorsFromQuery(query.expr);
// We are using stream[0] as log query can always have just 1 stream selector
const series = await this.datasource.languageProvider.fetchSeriesLabels(stream[0], { timeRange });
allLabels = Object.keys(series);
allLabels = await this.datasource.languageProvider.fetchLabels({ streamSelector: stream[0], timeRange });
}
const contextFilters: ContextFilter[] = [];

@ -15,7 +15,12 @@ export function createMetadataRequest(
const labelsMatch = url.match(lokiLabelsAndValuesEndpointRegex);
const seriesMatch = url.match(lokiSeriesEndpointRegex);
if (labelsMatch) {
return labelsAndValues[labelsMatch[1]] || [];
if (series && params && params['query']) {
const labelAndValue = series[params['query'] as string];
return labelAndValue.map((s) => s[labelsMatch[1]]) || [];
} else {
return labelsAndValues[labelsMatch[1]] || [];
}
} else if (seriesMatch && series && params) {
return series[params['match[]']] || [];
} else {

@ -47,7 +47,6 @@ const otherLabels: Label[] = [
op: '=',
},
];
const seriesLabels = { place: ['series', 'labels'], source: [], other: [] };
const parserAndLabelKeys = {
extractedLabelKeys: ['extracted', 'label', 'keys'],
unwrapLabelKeys: ['unwrap', 'labels'],
@ -77,8 +76,8 @@ describe('CompletionDataProvider', () => {
completionProvider = new CompletionDataProvider(languageProvider, historyRef, mockTimeRange);
jest.spyOn(languageProvider, 'getLabelKeys').mockReturnValue(labelKeys);
jest.spyOn(languageProvider, 'fetchLabels').mockResolvedValue(labelKeys);
jest.spyOn(languageProvider, 'fetchLabelValues').mockResolvedValue(labelValues);
jest.spyOn(languageProvider, 'fetchSeriesLabels').mockResolvedValue(seriesLabels);
jest.spyOn(languageProvider, 'getParserAndLabelKeys').mockResolvedValue(parserAndLabelKeys);
});
@ -102,21 +101,32 @@ describe('CompletionDataProvider', () => {
expect(completionProvider.getHistory()).toEqual(['{value="other"}']);
});
test('Returns the expected label names with no other labels', async () => {
test('Returns the expected label names', async () => {
expect(await completionProvider.getLabelNames([])).toEqual(labelKeys);
});
test('Returns the expected label names with other labels', async () => {
expect(await completionProvider.getLabelNames(otherLabels)).toEqual(['source', 'other']);
test('Returns the list of label names without labels used in selector', async () => {
expect(await completionProvider.getLabelNames(otherLabels)).toEqual(['source']);
});
test('Returns the expected label values with no other labels', async () => {
test('Correctly build stream selector in getLabelNames and pass it to fetchLabels call', async () => {
await completionProvider.getLabelNames([{ name: 'job', op: '=', value: '"a\\b\n' }]);
expect(languageProvider.fetchLabels).toHaveBeenCalledWith({
streamSelector: '{job="\\"a\\\\b\\n"}',
timeRange: mockTimeRange,
});
});
test('Returns the expected label values', async () => {
expect(await completionProvider.getLabelValues('label', [])).toEqual(labelValues);
});
test('Returns the expected label values with other labels', async () => {
expect(await completionProvider.getLabelValues('place', otherLabels)).toEqual(['series', 'labels']);
expect(await completionProvider.getLabelValues('other label', otherLabels)).toEqual([]);
test('Correctly build stream selector in getLabelValues and pass it to fetchLabelValues call', async () => {
await completionProvider.getLabelValues('place', [{ name: 'job', op: '=', value: '"a\\b\n' }]);
expect(languageProvider.fetchLabelValues).toHaveBeenCalledWith('place', {
streamSelector: '{job="\\"a\\\\b\\n"}',
timeRange: mockTimeRange,
});
});
test('Returns the expected parser and label keys', async () => {
@ -178,15 +188,4 @@ describe('CompletionDataProvider', () => {
completionProvider.getParserAndLabelKeys('');
expect(languageProvider.getParserAndLabelKeys).toHaveBeenCalledWith('', { timeRange: mockTimeRange });
});
test('Returns the expected series labels', async () => {
expect(await completionProvider.getSeriesLabels([])).toEqual(seriesLabels);
});
test('Escapes correct characters when building stream selector in getSeriesLabels', async () => {
completionProvider.getSeriesLabels([{ name: 'job', op: '=', value: '"a\\b\n' }]);
expect(languageProvider.fetchSeriesLabels).toHaveBeenCalledWith('{job="\\"a\\\\b\\n"}', {
timeRange: mockTimeRange,
});
});
});

@ -40,23 +40,24 @@ export class CompletionDataProvider {
async getLabelNames(otherLabels: Label[] = []) {
if (otherLabels.length === 0) {
// if there is no filtering, we have to use a special endpoint
// If there is no filtering, we use getLabelKeys because it has better caching
// and all labels should already be fetched
await this.languageProvider.start(this.timeRange);
return this.languageProvider.getLabelKeys();
}
const data = await this.getSeriesLabels(otherLabels);
const possibleLabelNames = Object.keys(data); // all names from datasource
const possibleLabelNames = await this.languageProvider.fetchLabels({
streamSelector: this.buildSelector(otherLabels),
timeRange: this.timeRange,
});
const usedLabelNames = new Set(otherLabels.map((l) => l.name)); // names used in the query
return possibleLabelNames.filter((label) => !usedLabelNames.has(label));
}
async getLabelValues(labelName: string, otherLabels: Label[]) {
if (otherLabels.length === 0) {
// if there is no filtering, we have to use a special endpoint
return await this.languageProvider.fetchLabelValues(labelName, { timeRange: this.timeRange });
}
const data = await this.getSeriesLabels(otherLabels);
return data[labelName] ?? [];
return await this.languageProvider.fetchLabelValues(labelName, {
streamSelector: this.buildSelector(otherLabels),
timeRange: this.timeRange,
});
}
/**
@ -89,10 +90,4 @@ export class CompletionDataProvider {
return labelKeys;
}
}
async getSeriesLabels(labels: Label[]) {
return await this.languageProvider
.fetchSeriesLabels(this.buildSelector(labels), { timeRange: this.timeRange })
.then((data) => data ?? {});
}
}

@ -490,9 +490,9 @@ describe('LokiDatasource', () => {
return { ds };
};
it('should return empty array if /series returns empty', async () => {
it('should return empty array if label values returns empty', async () => {
const ds = createLokiDatasource(templateSrvStub);
const spy = jest.spyOn(ds.languageProvider, 'fetchSeriesLabels').mockResolvedValue({});
const spy = jest.spyOn(ds.languageProvider, 'fetchLabelValues').mockResolvedValue([]);
const result = await ds.metricFindQuery({
refId: 'test',

@ -680,16 +680,10 @@ export class LokiDatasource
return [];
}
// If we have stream selector, use /series endpoint
if (query.stream) {
const result = await this.languageProvider.fetchSeriesLabels(query.stream, { timeRange });
if (!result[query.label]) {
return [];
}
return result[query.label].map((value: string) => ({ text: value }));
}
const result = await this.languageProvider.fetchLabelValues(query.label, { timeRange });
const result = await this.languageProvider.fetchLabelValues(query.label, {
streamSelector: query.stream,
timeRange,
});
return result.map((value: string) => ({ text: value }));
}

@ -25,16 +25,18 @@ We strongly advise using these recommended methods instead of direct API calls b
```ts
/**
* Fetch all label keys
* This asynchronous function is designed to retrieve all available label keys from the data source.
* Fetch label keys using the best applicable endpoint.
*
* This asynchronous function returns all available label keys from the data source.
* It returns a promise that resolves to an array of strings containing the label keys.
*
* @param options - (Optional) An object containing additional options - currently only time range.
* @param options - (Optional) An object containing additional options.
* @param options.streamSelector - (Optional) The stream selector to filter label keys. If not provided, all label keys are fetched.
* @param options.timeRange - (Optional) The time range for which you want to retrieve label keys. If not provided, the default time range is used.
* @returns A promise containing an array of label keys.
* @throws An error if the fetch operation fails.
*/
async function fetchLabels(options?: { timeRange?: TimeRange }): Promise<string[]>;
async function fetchLabels(options?: { streamSelector?: string; timeRange?: TimeRange }): Promise<string[]>;
/**
* Example usage:

@ -0,0 +1,71 @@
import { screen, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { ComponentProps } from 'react';
import { DataSourceApi } from '@grafana/data';
import { QueryBuilderOperation, QueryBuilderOperationParamDef } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { createLokiDatasource } from '../../__mocks__/datasource';
import { LokiDatasource } from '../../datasource';
import { LokiQueryModeller } from '../LokiQueryModeller';
import { LokiOperationId } from '../types';
import { LabelParamEditor } from './LabelParamEditor';
describe('LabelParamEditor', () => {
const queryHintsFeatureToggle = config.featureToggles.lokiQueryHints;
beforeAll(() => {
config.featureToggles.lokiQueryHints = true;
});
afterAll(() => {
config.featureToggles.lokiQueryHints = queryHintsFeatureToggle;
});
it('shows label options', async () => {
const props = createProps({}, ['label1', 'label2']);
render(<LabelParamEditor {...props} />);
const input = screen.getByRole('combobox');
await userEvent.click(input);
expect(screen.getByText('label1')).toBeInTheDocument();
expect(screen.getByText('label2')).toBeInTheDocument();
});
it('shows no label options if no samples are returned', async () => {
const props = createProps();
render(<LabelParamEditor {...props} />);
const input = screen.getByRole('combobox');
await userEvent.click(input);
expect(screen.getByText('No labels found')).toBeInTheDocument();
});
});
const createProps = (propsOverrides?: Partial<ComponentProps<typeof LabelParamEditor>>, mockedSample?: string[]) => {
const propsDefault = {
value: undefined,
onChange: jest.fn(),
onRunQuery: jest.fn(),
index: 1,
operationId: '1',
query: {
labels: [{ op: '=', label: 'foo', value: 'bar' }],
operations: [
{ id: LokiOperationId.CountOverTime, params: ['5m'] },
{ id: '__sum_by', params: ['job'] },
],
},
paramDef: {} as QueryBuilderOperationParamDef,
operation: {} as QueryBuilderOperation,
datasource: createLokiDatasource() as DataSourceApi,
queryModeller: {
renderLabels: jest.fn().mockReturnValue('sum by(job) (count_over_time({foo="bar"} [5m]))'),
} as unknown as LokiQueryModeller,
};
const props = { ...propsDefault, ...propsOverrides };
if (props.datasource instanceof LokiDatasource) {
const resolvedValue = mockedSample ?? [];
props.datasource.languageProvider.fetchLabels = jest.fn().mockResolvedValue(resolvedValue);
}
return props;
};

@ -55,9 +55,9 @@ async function loadGroupByLabels(
let labels: QueryBuilderLabelFilter[] = query.labels;
const queryString = queryModeller.renderLabels(labels);
const result = await datasource.languageProvider.fetchSeriesLabels(queryString);
const result: string[] = await datasource.languageProvider.fetchLabels({ streamSelector: queryString });
return Object.keys(result).map((x) => ({
return result.map((x) => ({
label: x,
value: x,
}));

@ -49,26 +49,26 @@ describe('LokiQueryBuilder', () => {
afterEach(() => {
config.featureToggles.lokiQueryHints = originalLokiQueryHints;
});
it('tries to load labels when no labels are selected', async () => {
it('tries to load label names', async () => {
const props = createDefaultProps();
props.datasource.getDataSamples = jest.fn().mockResolvedValue([]);
props.datasource.languageProvider.fetchSeriesLabels = jest.fn().mockReturnValue({ job: ['a'], instance: ['b'] });
props.datasource.languageProvider.fetchLabels = jest.fn().mockReturnValue(['job', 'instance']);
render(<LokiQueryBuilder {...props} query={defaultQuery} />);
await userEvent.click(screen.getByLabelText('Add'));
const labels = screen.getByText(/Label filters/);
const selects = getAllByRole(getSelectParent(labels)!, 'combobox');
await userEvent.click(selects[3]);
expect(props.datasource.languageProvider.fetchSeriesLabels).toHaveBeenCalledWith('{baz="bar"}', {
expect(props.datasource.languageProvider.fetchLabels).toHaveBeenCalledWith({
streamSelector: '{baz="bar"}',
timeRange: mockTimeRange,
});
await waitFor(() => expect(screen.getByText('job')).toBeInTheDocument());
});
it('uses fetchLabelValues preselected labels have no equality matcher', async () => {
it('uses fetchLabelValues if preselected labels have no equality matcher', async () => {
const props = createDefaultProps();
props.datasource.getDataSamples = jest.fn().mockResolvedValue([]);
props.datasource.languageProvider.fetchSeriesLabels = jest.fn();
props.datasource.languageProvider.fetchLabelValues = jest.fn().mockReturnValue(['a', 'b']);
const query: LokiVisualQuery = {
@ -85,13 +85,11 @@ describe('LokiQueryBuilder', () => {
expect(props.datasource.languageProvider.fetchLabelValues).toHaveBeenCalledWith('job', {
timeRange: mockTimeRange,
});
expect(props.datasource.languageProvider.fetchSeriesLabels).not.toBeCalled();
});
it('uses fetchLabelValues preselected label have regex equality matcher with match everything value (.*)', async () => {
it('no streamSelector in fetchLabelValues if preselected label have regex equality matcher with match everything value (.*)', async () => {
const props = createDefaultProps();
props.datasource.getDataSamples = jest.fn().mockResolvedValue([]);
props.datasource.languageProvider.fetchSeriesLabels = jest.fn();
props.datasource.languageProvider.fetchLabelValues = jest.fn().mockReturnValue(['a', 'b']);
const query: LokiVisualQuery = {
@ -108,13 +106,11 @@ describe('LokiQueryBuilder', () => {
expect(props.datasource.languageProvider.fetchLabelValues).toHaveBeenCalledWith('job', {
timeRange: mockTimeRange,
});
expect(props.datasource.languageProvider.fetchSeriesLabels).not.toBeCalled();
});
it('uses fetchLabels preselected label have regex equality matcher with match everything value (.*)', async () => {
it('no streamSelector in fetchLabels if preselected label have regex equality matcher with match everything value (.*)', async () => {
const props = createDefaultProps();
props.datasource.getDataSamples = jest.fn().mockResolvedValue([]);
props.datasource.languageProvider.fetchSeriesLabels = jest.fn();
props.datasource.languageProvider.fetchLabels = jest.fn().mockReturnValue(['a', 'b']);
const query: LokiVisualQuery = {
@ -129,14 +125,12 @@ describe('LokiQueryBuilder', () => {
const selects = getAllByRole(getSelectParent(labels)!, 'combobox');
await userEvent.click(selects[3]);
expect(props.datasource.languageProvider.fetchLabels).toHaveBeenCalledWith({ timeRange: mockTimeRange });
expect(props.datasource.languageProvider.fetchSeriesLabels).not.toBeCalled();
});
it('uses fetchSeriesLabels preselected label have regex equality matcher', async () => {
it('uses streamSelector in fetchLabelValues if preselected label have regex equality matcher', async () => {
const props = createDefaultProps();
props.datasource.getDataSamples = jest.fn().mockResolvedValue([]);
props.datasource.languageProvider.fetchSeriesLabels = jest.fn().mockReturnValue({ job: ['a'], instance: ['b'] });
props.datasource.languageProvider.fetchLabelValues = jest.fn();
props.datasource.languageProvider.fetchLabelValues = jest.fn().mockReturnValue(['a', 'b']);
const query: LokiVisualQuery = {
labels: [
@ -149,18 +143,17 @@ describe('LokiQueryBuilder', () => {
const labels = screen.getByText(/Label filters/);
const selects = getAllByRole(getSelectParent(labels)!, 'combobox');
await userEvent.click(selects[5]);
expect(props.datasource.languageProvider.fetchSeriesLabels).toHaveBeenCalledWith('{cluster=~"cluster1|cluster2"}', {
expect(props.datasource.languageProvider.fetchLabelValues).toHaveBeenCalledWith('job', {
streamSelector: '{cluster=~"cluster1|cluster2"}',
timeRange: mockTimeRange,
});
expect(props.datasource.languageProvider.fetchLabelValues).not.toBeCalled();
});
it('does refetch label values with the correct time range', async () => {
const props = createDefaultProps();
props.datasource.getDataSamples = jest.fn().mockResolvedValue([]);
props.datasource.languageProvider.fetchSeriesLabels = jest
.fn()
.mockReturnValue({ job: ['a'], instance: ['b'], baz: ['bar'] });
props.datasource.languageProvider.fetchLabels = jest.fn().mockReturnValue(['job', 'instance', 'baz']);
props.datasource.languageProvider.fetchLabelValues = jest.fn().mockReturnValue(['a', 'b', 'c']);
render(<LokiQueryBuilder {...props} query={defaultQuery} />);
await userEvent.click(screen.getByLabelText('Add'));
@ -170,7 +163,12 @@ describe('LokiQueryBuilder', () => {
await waitFor(() => expect(screen.getByText('job')).toBeInTheDocument());
await userEvent.click(screen.getByText('job'));
await userEvent.click(selects[5]);
expect(props.datasource.languageProvider.fetchSeriesLabels).toHaveBeenNthCalledWith(2, '{baz="bar"}', {
expect(props.datasource.languageProvider.fetchLabels).toHaveBeenCalledWith({
streamSelector: '{baz="bar"}',
timeRange: mockTimeRange,
});
expect(props.datasource.languageProvider.fetchLabelValues).toHaveBeenCalledWith('job', {
streamSelector: '{baz="bar"}',
timeRange: mockTimeRange,
});
});
@ -178,9 +176,7 @@ describe('LokiQueryBuilder', () => {
it('does not show already existing label names as option in label filter', async () => {
const props = createDefaultProps();
props.datasource.getDataSamples = jest.fn().mockResolvedValue([]);
props.datasource.languageProvider.fetchSeriesLabels = jest
.fn()
.mockReturnValue({ job: ['a'], instance: ['b'], baz: ['bar'] });
props.datasource.languageProvider.fetchLabels = jest.fn().mockReturnValue(['job', 'instance', 'baz']);
render(<LokiQueryBuilder {...props} query={defaultQuery} />);
await userEvent.click(screen.getByLabelText('Add'));

@ -65,16 +65,15 @@ export const LokiQueryBuilder = React.memo<Props>(
return await datasource.languageProvider.fetchLabels({ timeRange });
}
const expr = lokiQueryModeller.renderLabels(labelsToConsider);
const series = await datasource.languageProvider.fetchSeriesLabels(expr, { timeRange });
const streamSelector = lokiQueryModeller.renderLabels(labelsToConsider);
const possibleLabelNames = await datasource.languageProvider.fetchLabels({
streamSelector,
timeRange,
});
const labelsNamesToConsider = labelsToConsider.map((l) => l.label);
const labelNames = Object.keys(series)
// Filter out label names that are already selected
.filter((name) => !labelsNamesToConsider.includes(name))
.sort();
return labelNames;
// Filter out label names that are already selected
return possibleLabelNames.filter((label) => !labelsNamesToConsider.includes(label)).sort();
};
const onGetLabelValues = async (forLabel: Partial<QueryBuilderLabelFilter>) => {
@ -91,9 +90,11 @@ export const LokiQueryBuilder = React.memo<Props>(
if (labelsToConsider.length === 0 || !hasEqualityOperation) {
values = await datasource.languageProvider.fetchLabelValues(forLabel.label, { timeRange });
} else {
const expr = lokiQueryModeller.renderLabels(labelsToConsider);
const result = await datasource.languageProvider.fetchSeriesLabels(expr, { timeRange });
values = result[datasource.interpolateString(forLabel.label)];
const streamSelector = lokiQueryModeller.renderLabels(labelsToConsider);
values = await datasource.languageProvider.fetchLabelValues(forLabel.label, {
streamSelector,
timeRange,
});
}
return values ? values.map((v) => escapeLabelValueInSelector(v, forLabel.op)) : []; // Escape values in return

@ -42,7 +42,8 @@ describe('LokiQueryBuilderContainer', () => {
showExplain: false,
};
props.datasource.getDataSamples = jest.fn().mockResolvedValue([]);
props.datasource.languageProvider.fetchSeriesLabels = jest.fn().mockReturnValue({ job: ['grafana', 'loki'] });
props.datasource.languageProvider.fetchLabels = jest.fn().mockReturnValue(['job']);
props.datasource.languageProvider.fetchLabelValues = jest.fn().mockReturnValue(['grafana', 'loki']);
props.onChange = jest.fn();
render(<LokiQueryBuilderContainer {...props} />);

@ -8,7 +8,7 @@ import { EXPLAIN_LABEL_FILTER_CONTENT } from './LokiQueryBuilderExplained';
import { LokiQueryCodeEditor } from './LokiQueryCodeEditor';
const defaultQuery: LokiQuery = {
expr: '{job="bar}',
expr: '{job="bar"}',
refId: 'A',
};

Loading…
Cancel
Save