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. Frozen indices are [deprecated in Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.17/frozen-indices.html) since v7.14.
{{< /admonition >}} {{< /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 ### Logs
In this section you can configure which fields the data source uses for log messages and log levels. 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'; } from './dataquery.gen';
import { import {
defaultBucketAgg, defaultBucketAgg,
defaultMetricAgg,
findMetricById, findMetricById,
highlightTags, highlightTags,
defaultGeoHashPrecisionString, defaultGeoHashPrecisionString,
queryTypeToMetricType,
} from './queryDef'; } from './queryDef';
import { TermsQuery } from './types'; import { QueryType, TermsQuery } from './types';
import { convertOrderByToMetricId, getScriptValue } from './utils'; import { convertOrderByToMetricId, getScriptValue } from './utils';
// Omitting 1m, 1h, 1d for now, as these cover the main use cases for calendar_interval // 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 { export class ElasticQueryBuilder {
timeField: string; timeField: string;
defaultQueryMode?: QueryType;
constructor(options: { timeField: string }) { constructor(options: { timeField: string; defaultQueryMode?: QueryType }) {
this.timeField = options.timeField; this.timeField = options.timeField;
this.defaultQueryMode = options.defaultQueryMode;
} }
getRangeFilter() { getRangeFilter() {
@@ -174,7 +176,10 @@ export class ElasticQueryBuilder {
build(target: ElasticsearchDataQuery) { build(target: ElasticsearchDataQuery) {
// make sure query has defaults; // 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.bucketAggs = target.bucketAggs || [defaultBucketAgg()];
target.timeField = this.timeField; target.timeField = this.timeField;
let metric: MetricAggregation; let metric: MetricAggregation;

View File

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

View File

@@ -2,7 +2,7 @@ import { Action } from '@reduxjs/toolkit';
import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultMetricAgg } from '../../../../queryDef'; import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef';
import { removeEmpty } from '../../../../utils'; import { removeEmpty } from '../../../../utils';
import { initQuery } from '../../state'; import { initQuery } from '../../state';
import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, isPipelineAggregation } from '../aggregations'; import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, isPipelineAggregation } from '../aggregations';
@@ -162,7 +162,8 @@ export const reducer = (
if (state && state.length > 0) { if (state && state.length > 0) {
return state; return state;
} }
return [defaultMetricAgg('1')]; const metricType = queryTypeToMetricType(action.payload);
return [{ type: metricType, id: '1' }];
} }
return state; 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 { RadioButtonGroup } from '@grafana/ui';
import { MetricAggregation } from '../../dataquery.gen'; import { QUERY_TYPE_SELECTOR_OPTIONS } from '../../configuration/utils';
import { useDispatch } from '../../hooks/useStatelessReducer'; import { useDispatch } from '../../hooks/useStatelessReducer';
import { queryTypeToMetricType } from '../../queryDef';
import { QueryType } from '../../types'; import { QueryType } from '../../types';
import { useQuery } from './ElasticsearchQueryContext'; import { useQuery } from './ElasticsearchQueryContext';
import { changeMetricType } from './MetricAggregationsEditor/state/actions'; import { changeMetricType } from './MetricAggregationsEditor/state/actions';
import { metricAggregationConfig } from './MetricAggregationsEditor/utils'; 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 = () => { export const QueryTypeSelector = () => {
const query = useQuery(); const query = useQuery();
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -47,5 +26,12 @@ export const QueryTypeSelector = () => {
dispatch(changeMetricType({ id: firstMetric.id, type: queryTypeToMetricType(newQueryType) })); 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 { Action, createAction } from '@reduxjs/toolkit';
import { ElasticsearchDataQuery } from '../../dataquery.gen'; 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. * 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. * 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'); 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 * 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 { ConfigDescriptionLink, ConfigSubSection } from '@grafana/plugin-ui';
import { InlineField, Input, Select, InlineSwitch } from '@grafana/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>> = [ const indexPatternTypes: Array<SelectableValue<'none' | Interval>> = [
{ label: 'No pattern', value: 'none' }, { label: 'No pattern', value: 'none' },
@@ -127,6 +129,29 @@ export const ElasticDetails = ({ value, onChange }: Props) => {
onChange={jsonDataSwitchChangeHandler('includeFrozen', value, onChange)} onChange={jsonDataSwitchChangeHandler('includeFrozen', value, onChange)}
/> />
</InlineField> </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> </ConfigSubSection>
); );
}; };
@@ -209,3 +234,6 @@ const intervalHandler =
export function defaultMaxConcurrentShardRequests() { export function defaultMaxConcurrentShardRequests() {
return 5; return 5;
} }
export function defaultQueryMode(): QueryType {
return 'metrics';
}

View File

@@ -11,6 +11,7 @@ export function createDefaultConfigOptions(): DataSourceSettings<ElasticsearchOp
maxConcurrentShardRequests: 300, maxConcurrentShardRequests: 300,
logMessageField: 'test.message', logMessageField: 'test.message',
logLevelField: 'test.level', logLevelField: 'test.level',
defaultQueryMode: 'metrics',
}, },
secureJsonFields: {}, secureJsonFields: {},
} as DataSourceSettings<ElasticsearchOptions>; } 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 = ( export const coerceOptions = (
options: DataSourceSettings<ElasticsearchOptions, {}> options: DataSourceSettings<ElasticsearchOptions, {}>
@@ -16,6 +23,7 @@ export const coerceOptions = (
logMessageField: options.jsonData.logMessageField || '', logMessageField: options.jsonData.logMessageField || '',
logLevelField: options.jsonData.logLevelField || '', logLevelField: options.jsonData.logLevelField || '',
includeFrozen: options.jsonData.includeFrozen ?? false, includeFrozen: options.jsonData.includeFrozen ?? false,
defaultQueryMode: options.jsonData.defaultQueryMode || defaultQueryMode(),
}, },
}; };
}; };

View File

@@ -81,6 +81,7 @@ import {
isElasticsearchResponseWithAggregations, isElasticsearchResponseWithAggregations,
isElasticsearchResponseWithHits, isElasticsearchResponseWithHits,
ElasticsearchHits, ElasticsearchHits,
QueryType,
} from './types'; } from './types';
import { getScriptValue, isTimeSeriesQuery } from './utils'; import { getScriptValue, isTimeSeriesQuery } from './utils';
@@ -127,6 +128,7 @@ export class ElasticDatasource
includeFrozen: boolean; includeFrozen: boolean;
isProxyAccess: boolean; isProxyAccess: boolean;
databaseVersion: SemVer | null; databaseVersion: SemVer | null;
defaultQueryMode?: QueryType;
constructor( constructor(
instanceSettings: DataSourceInstanceSettings<ElasticsearchOptions>, instanceSettings: DataSourceInstanceSettings<ElasticsearchOptions>,
@@ -146,9 +148,6 @@ export class ElasticDatasource
this.intervalPattern = settingsData.interval; this.intervalPattern = settingsData.interval;
this.interval = settingsData.timeInterval; this.interval = settingsData.timeInterval;
this.maxConcurrentShardRequests = settingsData.maxConcurrentShardRequests; this.maxConcurrentShardRequests = settingsData.maxConcurrentShardRequests;
this.queryBuilder = new ElasticQueryBuilder({
timeField: this.timeField,
});
this.logLevelField = settingsData.logLevelField || ''; this.logLevelField = settingsData.logLevelField || '';
this.dataLinks = settingsData.dataLinks || []; this.dataLinks = settingsData.dataLinks || [];
this.includeFrozen = settingsData.includeFrozen ?? false; this.includeFrozen = settingsData.includeFrozen ?? false;
@@ -157,7 +156,11 @@ export class ElasticDatasource
this.annotations = { this.annotations = {
QueryEditor: ElasticsearchAnnotationsQueryEditor, QueryEditor: ElasticsearchAnnotationsQueryEditor,
}; };
this.defaultQueryMode = settingsData.defaultQueryMode;
this.queryBuilder = new ElasticQueryBuilder({
timeField: this.timeField,
defaultQueryMode: this.defaultQueryMode,
});
if (this.logLevelField === '') { if (this.logLevelField === '') {
this.logLevelField = undefined; 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('ElasticQueryDef', () => {
describe('isPipelineMetric', () => { 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, MetricAggregationType,
MovingAverageModelOption, MovingAverageModelOption,
} from './dataquery.gen'; } from './dataquery.gen';
import type { QueryType } from './types';
export const extendedStats: ExtendedStat[] = [ export const extendedStats: ExtendedStat[] = [
{ label: 'Avg', value: 'avg' }, { label: 'Avg', value: 'avg' },
@@ -42,6 +43,24 @@ export function defaultBucketAgg(id = '1'): DateHistogram {
return { type: 'date_histogram', id, settings: { interval: 'auto' } }; 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']) => export const findMetricById = (metrics: MetricAggregation[], id: MetricAggregation['id']) =>
metrics.find((metric) => metric.id === id); metrics.find((metric) => metric.id === id);

View File

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