Compare commits

...

6 Commits

Author SHA1 Message Date
Andrew Hackmann
d419c33970 Merge remote-tracking branch 'origin/main' into elasticsearch-datasource-config-option 2025-12-08 16:32:02 -06:00
Andrew Hackmann
9b6f306956 retigger CI 2025-12-08 16:29:13 -06:00
Caue Marcondes
598be0cf49 addressing PR comments 2025-11-20 14:17:02 -05:00
Caue Marcondes
bad5bd627d syncing default query mode with url 2025-10-17 15:12:02 -04:00
Caue Marcondes
850963c8fe doc 2025-10-16 15:46:45 -04:00
Caue Marcondes
5e79510352 elasticsearch: Add default query mode config setting 2025-10-16 15:38:10 -04:00
15 changed files with 273 additions and 43 deletions

View File

@@ -174,6 +174,8 @@ You can also override this setting in a dashboard panel under its data source op
Frozen indices are [deprecated in Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.17/frozen-indices.html) since v7.14.
{{< /admonition >}}
- **Default query mode** - Specifies which query mode the data source uses by default. Options are `Metrics`, `Logs`, `Raw data`, and `Raw document`. The default is `Metrics`.
### Logs
In this section you can configure which fields the data source uses for log messages and log levels.

View File

@@ -18,12 +18,12 @@ import {
} from './dataquery.gen';
import {
defaultBucketAgg,
defaultMetricAgg,
findMetricById,
highlightTags,
defaultGeoHashPrecisionString,
queryTypeToMetricType,
} from './queryDef';
import { TermsQuery } from './types';
import { QueryType, TermsQuery } from './types';
import { convertOrderByToMetricId, getScriptValue } from './utils';
// Omitting 1m, 1h, 1d for now, as these cover the main use cases for calendar_interval
@@ -31,9 +31,11 @@ export const calendarIntervals: string[] = ['1w', '1M', '1q', '1y'];
export class ElasticQueryBuilder {
timeField: string;
defaultQueryMode?: QueryType;
constructor(options: { timeField: string }) {
constructor(options: { timeField: string; defaultQueryMode?: QueryType }) {
this.timeField = options.timeField;
this.defaultQueryMode = options.defaultQueryMode;
}
getRangeFilter() {
@@ -174,7 +176,10 @@ export class ElasticQueryBuilder {
build(target: ElasticsearchDataQuery) {
// make sure query has defaults;
target.metrics = target.metrics || [defaultMetricAgg()];
if (!target.metrics || target.metrics.length === 0) {
const metricType = queryTypeToMetricType(this.defaultQueryMode);
target.metrics = [{ type: metricType, id: '1' }];
}
target.bucketAggs = target.bucketAggs || [defaultBucketAgg()];
target.timeField = this.timeField;
let metric: MetricAggregation;

View File

@@ -62,10 +62,10 @@ export const ElasticsearchProvider = ({
// useStatelessReducer will then call `onChange` with the newly generated query
useEffect(() => {
if (shouldRunInit && isUninitialized) {
dispatch(initQuery());
dispatch(initQuery(datasource.defaultQueryMode));
setShouldRunInit(false);
}
}, [shouldRunInit, dispatch, isUninitialized]);
}, [shouldRunInit, dispatch, isUninitialized, datasource.defaultQueryMode]);
if (isUninitialized) {
return null;

View File

@@ -2,7 +2,7 @@ import { Action } from '@reduxjs/toolkit';
import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultMetricAgg } from '../../../../queryDef';
import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef';
import { removeEmpty } from '../../../../utils';
import { initQuery } from '../../state';
import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, isPipelineAggregation } from '../aggregations';
@@ -162,7 +162,8 @@ export const reducer = (
if (state && state.length > 0) {
return state;
}
return [defaultMetricAgg('1')];
const metricType = queryTypeToMetricType(action.payload);
return [{ type: metricType, id: '1' }];
}
return state;

View File

@@ -0,0 +1,117 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ElasticsearchDataQuery } from '../../dataquery.gen';
import { useDispatch } from '../../hooks/useStatelessReducer';
import { renderWithESProvider } from '../../test-helpers/render';
import { changeMetricType } from './MetricAggregationsEditor/state/actions';
import { QueryTypeSelector } from './QueryTypeSelector';
jest.mock('../../hooks/useStatelessReducer');
describe('QueryTypeSelector', () => {
let dispatch: jest.Mock;
beforeEach(() => {
dispatch = jest.fn();
jest.mocked(useDispatch).mockReturnValue(dispatch);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should render radio buttons with correct options', () => {
const query: ElasticsearchDataQuery = {
refId: 'A',
query: '',
metrics: [{ id: '1', type: 'count' }],
bucketAggs: [{ type: 'date_histogram', id: '2' }],
};
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
expect(screen.getByRole('radio', { name: 'Metrics' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Logs' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Raw Data' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Raw Document' })).toBeInTheDocument();
});
it('should dispatch changeMetricType action when radio button is changed', async () => {
const query: ElasticsearchDataQuery = {
refId: 'A',
query: '',
metrics: [{ id: '1', type: 'count' }],
bucketAggs: [{ type: 'date_histogram', id: '2' }],
};
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
const logsRadio = screen.getByRole('radio', { name: 'Logs' });
await userEvent.click(logsRadio);
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'logs' }));
});
it('should convert query type to metric type correctly for raw_data', async () => {
const query: ElasticsearchDataQuery = {
refId: 'A',
query: '',
metrics: [{ id: '1', type: 'count' }],
bucketAggs: [{ type: 'date_histogram', id: '2' }],
};
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
const rawDataRadio = screen.getByRole('radio', { name: 'Raw Data' });
await userEvent.click(rawDataRadio);
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'raw_data' }));
});
it('should convert query type to metric type correctly for raw_document', async () => {
const query: ElasticsearchDataQuery = {
refId: 'A',
query: '',
metrics: [{ id: '1', type: 'count' }],
bucketAggs: [{ type: 'date_histogram', id: '2' }],
};
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
const rawDocumentRadio = screen.getByRole('radio', { name: 'Raw Document' });
await userEvent.click(rawDocumentRadio);
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'raw_document' }));
});
it('should convert metrics query type to count metric type', async () => {
const query: ElasticsearchDataQuery = {
refId: 'A',
query: '',
metrics: [{ id: '1', type: 'logs' }],
bucketAggs: [{ type: 'date_histogram', id: '2' }],
};
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
const metricsRadio = screen.getByRole('radio', { name: 'Metrics' });
await userEvent.click(metricsRadio);
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'count' }));
});
it('should return null when query has no metrics', () => {
const query: ElasticsearchDataQuery = {
refId: 'A',
query: '',
metrics: [],
bucketAggs: [{ type: 'date_histogram', id: '2' }],
};
const { container } = renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
expect(container.firstChild).toBeNull();
});
});

View File

@@ -1,35 +1,14 @@
import { SelectableValue } from '@grafana/data';
import { RadioButtonGroup } from '@grafana/ui';
import { MetricAggregation } from '../../dataquery.gen';
import { QUERY_TYPE_SELECTOR_OPTIONS } from '../../configuration/utils';
import { useDispatch } from '../../hooks/useStatelessReducer';
import { queryTypeToMetricType } from '../../queryDef';
import { QueryType } from '../../types';
import { useQuery } from './ElasticsearchQueryContext';
import { changeMetricType } from './MetricAggregationsEditor/state/actions';
import { metricAggregationConfig } from './MetricAggregationsEditor/utils';
const OPTIONS: Array<SelectableValue<QueryType>> = [
{ value: 'metrics', label: 'Metrics' },
{ value: 'logs', label: 'Logs' },
{ value: 'raw_data', label: 'Raw Data' },
{ value: 'raw_document', label: 'Raw Document' },
];
function queryTypeToMetricType(type: QueryType): MetricAggregation['type'] {
switch (type) {
case 'logs':
case 'raw_data':
case 'raw_document':
return type;
case 'metrics':
return 'count';
default:
// should never happen
throw new Error(`invalid query type: ${type}`);
}
}
export const QueryTypeSelector = () => {
const query = useQuery();
const dispatch = useDispatch();
@@ -47,5 +26,12 @@ export const QueryTypeSelector = () => {
dispatch(changeMetricType({ id: firstMetric.id, type: queryTypeToMetricType(newQueryType) }));
};
return <RadioButtonGroup<QueryType> fullWidth={false} options={OPTIONS} value={queryType} onChange={onChange} />;
return (
<RadioButtonGroup<QueryType>
fullWidth={false}
options={QUERY_TYPE_SELECTOR_OPTIONS}
value={queryType}
onChange={onChange}
/>
);
};

View File

@@ -1,12 +1,13 @@
import { Action, createAction } from '@reduxjs/toolkit';
import { ElasticsearchDataQuery } from '../../dataquery.gen';
import { QueryType } from '../../types';
/**
* When the `initQuery` Action is dispatched, the query gets populated with default values where values are not present.
* This means it won't override any existing value in place, but just ensure the query is in a "runnable" state.
*/
export const initQuery = createAction('init');
export const initQuery = createAction<QueryType | undefined>('init');
export const changeQuery = createAction<ElasticsearchDataQuery['query']>('change_query');

View File

@@ -41,4 +41,16 @@ describe('ElasticDetails', () => {
})
);
});
it('should change default query mode when selected', async () => {
const onChangeMock = jest.fn();
render(<ElasticDetails onChange={onChangeMock} value={createDefaultConfigOptions()} />);
const selectEl = screen.getByLabelText('Default query mode');
await selectEvent.select(selectEl, 'Logs', { container: document.body });
expect(onChangeMock).toHaveBeenLastCalledWith(
expect.objectContaining({ jsonData: expect.objectContaining({ defaultQueryMode: 'logs' }) })
);
});
});

View File

@@ -1,10 +1,12 @@
import * as React from 'react';
import { DataSourceSettings, SelectableValue } from '@grafana/data';
import type { DataSourceSettings, SelectableValue } from '@grafana/data';
import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/plugin-ui';
import { InlineField, Input, Select, InlineSwitch } from '@grafana/ui';
import { ElasticsearchOptions, Interval } from '../types';
import type { ElasticsearchOptions, Interval, QueryType } from '../types';
import { QUERY_TYPE_SELECTOR_OPTIONS } from './utils';
const indexPatternTypes: Array<SelectableValue<'none' | Interval>> = [
{ label: 'No pattern', value: 'none' },
@@ -127,6 +129,29 @@ export const ElasticDetails = ({ value, onChange }: Props) => {
onChange={jsonDataSwitchChangeHandler('includeFrozen', value, onChange)}
/>
</InlineField>
<InlineField
label="Default query mode"
htmlFor="es_config_defaultQueryMode"
labelWidth={29}
tooltip="Default query mode to use for the data source. Defaults to 'Metrics'."
>
<Select
inputId="es_config_defaultQueryMode"
value={value.jsonData.defaultQueryMode}
options={QUERY_TYPE_SELECTOR_OPTIONS}
onChange={(selectedMode) => {
onChange({
...value,
jsonData: {
...value.jsonData,
defaultQueryMode: selectedMode.value,
},
});
}}
width={24}
/>
</InlineField>
</ConfigSubSection>
);
};
@@ -209,3 +234,6 @@ const intervalHandler =
export function defaultMaxConcurrentShardRequests() {
return 5;
}
export function defaultQueryMode(): QueryType {
return 'metrics';
}

View File

@@ -11,6 +11,7 @@ export function createDefaultConfigOptions(): DataSourceSettings<ElasticsearchOp
maxConcurrentShardRequests: 300,
logMessageField: 'test.message',
logLevelField: 'test.level',
defaultQueryMode: 'metrics',
},
secureJsonFields: {},
} as DataSourceSettings<ElasticsearchOptions>;

View File

@@ -1,8 +1,15 @@
import { DataSourceSettings } from '@grafana/data';
import { DataSourceSettings, SelectableValue } from '@grafana/data';
import { ElasticsearchOptions } from '../types';
import { ElasticsearchOptions, QueryType } from '../types';
import { defaultMaxConcurrentShardRequests } from './ElasticDetails';
import { defaultMaxConcurrentShardRequests, defaultQueryMode } from './ElasticDetails';
export const QUERY_TYPE_SELECTOR_OPTIONS: Array<SelectableValue<QueryType>> = [
{ value: 'metrics', label: 'Metrics' },
{ value: 'logs', label: 'Logs' },
{ value: 'raw_data', label: 'Raw Data' },
{ value: 'raw_document', label: 'Raw Document' },
];
export const coerceOptions = (
options: DataSourceSettings<ElasticsearchOptions, {}>
@@ -16,6 +23,7 @@ export const coerceOptions = (
logMessageField: options.jsonData.logMessageField || '',
logLevelField: options.jsonData.logLevelField || '',
includeFrozen: options.jsonData.includeFrozen ?? false,
defaultQueryMode: options.jsonData.defaultQueryMode || defaultQueryMode(),
},
};
};

View File

@@ -81,6 +81,7 @@ import {
isElasticsearchResponseWithAggregations,
isElasticsearchResponseWithHits,
ElasticsearchHits,
QueryType,
} from './types';
import { getScriptValue, isTimeSeriesQuery } from './utils';
@@ -127,6 +128,7 @@ export class ElasticDatasource
includeFrozen: boolean;
isProxyAccess: boolean;
databaseVersion: SemVer | null;
defaultQueryMode?: QueryType;
constructor(
instanceSettings: DataSourceInstanceSettings<ElasticsearchOptions>,
@@ -146,9 +148,6 @@ export class ElasticDatasource
this.intervalPattern = settingsData.interval;
this.interval = settingsData.timeInterval;
this.maxConcurrentShardRequests = settingsData.maxConcurrentShardRequests;
this.queryBuilder = new ElasticQueryBuilder({
timeField: this.timeField,
});
this.logLevelField = settingsData.logLevelField || '';
this.dataLinks = settingsData.dataLinks || [];
this.includeFrozen = settingsData.includeFrozen ?? false;
@@ -157,7 +156,11 @@ export class ElasticDatasource
this.annotations = {
QueryEditor: ElasticsearchAnnotationsQueryEditor,
};
this.defaultQueryMode = settingsData.defaultQueryMode;
this.queryBuilder = new ElasticQueryBuilder({
timeField: this.timeField,
defaultQueryMode: this.defaultQueryMode,
});
if (this.logLevelField === '') {
this.logLevelField = undefined;
}

View File

@@ -1,4 +1,5 @@
import { isPipelineAgg, isPipelineAggWithMultipleBucketPaths } from './queryDef';
import { isPipelineAgg, isPipelineAggWithMultipleBucketPaths, queryTypeToMetricType } from './queryDef';
import type { QueryType } from './types';
describe('ElasticQueryDef', () => {
describe('isPipelineMetric', () => {
@@ -36,4 +37,49 @@ describe('ElasticQueryDef', () => {
});
});
});
describe('queryTypeToMetricType', () => {
describe('when type is undefined', () => {
test('should return count as default', () => {
const result = queryTypeToMetricType(undefined);
expect(result).toBe('count');
});
});
describe('when type is metrics', () => {
test('should return count', () => {
const result = queryTypeToMetricType('metrics' as QueryType);
expect(result).toBe('count');
});
});
describe('when type is logs', () => {
test('should return logs', () => {
const result = queryTypeToMetricType('logs' as QueryType);
expect(result).toBe('logs');
});
});
describe('when type is raw_data', () => {
test('should return raw_data', () => {
const result = queryTypeToMetricType('raw_data' as QueryType);
expect(result).toBe('raw_data');
});
});
describe('when type is raw_document', () => {
test('should return raw_document', () => {
const result = queryTypeToMetricType('raw_document' as QueryType);
expect(result).toBe('raw_document');
});
});
describe('when type is invalid', () => {
test('should throw an error', () => {
expect(() => {
queryTypeToMetricType('invalid_type' as QueryType);
}).toThrow('invalid query type: invalid_type');
});
});
});
});

View File

@@ -7,6 +7,7 @@ import {
MetricAggregationType,
MovingAverageModelOption,
} from './dataquery.gen';
import type { QueryType } from './types';
export const extendedStats: ExtendedStat[] = [
{ label: 'Avg', value: 'avg' },
@@ -42,6 +43,24 @@ export function defaultBucketAgg(id = '1'): DateHistogram {
return { type: 'date_histogram', id, settings: { interval: 'auto' } };
}
export function queryTypeToMetricType(type?: QueryType): MetricAggregation['type'] {
if (!type) {
return 'count'; // Default fallback
}
switch (type) {
case 'logs':
case 'raw_data':
case 'raw_document':
return type;
case 'metrics':
return 'count';
default:
// should never happen
throw new Error(`invalid query type: ${type}`);
}
}
export const findMetricById = (metrics: MetricAggregation[], id: MetricAggregation['id']) =>
metrics.find((metric) => metric.id === id);

View File

@@ -63,6 +63,7 @@ export interface ElasticsearchOptions extends DataSourceJsonData {
index?: string;
sigV4Auth?: boolean;
oauthPassThru?: boolean;
defaultQueryMode?: QueryType;
}
export type QueryType = 'metrics' | 'logs' | 'raw_data' | 'raw_document';