Explore: Add feature to open log sample in split view (#62097)

* Add tests

* Implement split open to see logs functionality

* Fix imports in test

* Update packages/grafana-data/src/types/logs.ts

Co-authored-by: Matias Chomicki <matyax@gmail.com>

* Update packages/grafana-data/src/types/logs.ts

Co-authored-by: Matias Chomicki <matyax@gmail.com>

* Update default scneario to throw error

* Exit early in getSupplementaryQuery

* Update public/app/features/explore/LogsSamplePanel.tsx

Co-authored-by: Matias Chomicki <matyax@gmail.com>
pull/62211/head
Ivana Huckova 2 years ago committed by GitHub
parent 5e1dc22f88
commit ea1fcbb866
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 13
      packages/grafana-data/src/types/logs.ts
  2. 6
      public/app/features/explore/Explore.tsx
  3. 40
      public/app/features/explore/LogsSample.test.tsx
  4. 78
      public/app/features/explore/LogsSamplePanel.tsx
  5. 1
      public/app/features/explore/state/helpers.ts
  6. 1
      public/app/features/explore/state/query.test.ts
  7. 51
      public/app/plugins/datasource/elasticsearch/datasource.test.ts
  8. 106
      public/app/plugins/datasource/elasticsearch/datasource.ts
  9. 93
      public/app/plugins/datasource/loki/datasource.test.ts
  10. 106
      public/app/plugins/datasource/loki/datasource.ts

@ -195,11 +195,23 @@ export enum SupplementaryQueryType {
* @internal
*/
export interface DataSourceWithSupplementaryQueriesSupport<TQuery extends DataQuery> {
/**
* Returns an observable that will be used to fetch supplementary data based on the provided
* supplementary query type and original request.
*/
getDataProvider(
type: SupplementaryQueryType,
request: DataQueryRequest<TQuery>
): Observable<DataQueryResponse> | undefined;
/**
* Returns supplementary query types that data source supports.
*/
getSupportedSupplementaryQueryTypes(): SupplementaryQueryType[];
/**
* Returns a supplementary query to be used to fetch supplementary data based on the provided type and original query.
* If provided query is not suitable for provided supplementary query type, undefined should be returned.
*/
getSupplementaryQuery(type: SupplementaryQueryType, query: TQuery): TQuery | undefined;
}
export const hasSupplementaryQuerySupport = <TQuery extends DataQuery>(
@ -214,6 +226,7 @@ export const hasSupplementaryQuerySupport = <TQuery extends DataQuery>(
return (
withSupplementaryQueriesSupport.getDataProvider !== undefined &&
withSupplementaryQueriesSupport.getSupplementaryQuery !== undefined &&
withSupplementaryQueriesSupport.getSupportedSupplementaryQueryTypes().includes(type)
);
};

@ -367,15 +367,17 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
}
renderLogsSamplePanel() {
const { logsSample, timeZone, setSupplementaryQueryEnabled, exploreId, datasourceInstance } = this.props;
const { logsSample, timeZone, setSupplementaryQueryEnabled, exploreId, datasourceInstance, queries } = this.props;
return (
<LogsSamplePanel
queryResponse={logsSample.data}
timeZone={timeZone}
enabled={logsSample.enabled}
queries={queries}
datasourceInstance={datasourceInstance}
setLogsSampleEnabled={(enabled) =>
splitOpen={this.onSplitOpen('logsSample')}
setLogsSampleEnabled={(enabled: boolean) =>
setSupplementaryQueryEnabled(exploreId, enabled, SupplementaryQueryType.LogsSample)
}
/>

@ -2,7 +2,15 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { ComponentProps } from 'react';
import { ArrayVector, FieldType, LoadingState, MutableDataFrame } from '@grafana/data';
import {
ArrayVector,
FieldType,
LoadingState,
MutableDataFrame,
SupplementaryQueryType,
DataSourceApi,
} from '@grafana/data';
import { DataQuery } from '@grafana/schema';
import { LogsSamplePanel } from './LogsSamplePanel';
@ -20,6 +28,8 @@ const createProps = (propOverrides?: Partial<ComponentProps<typeof LogsSamplePan
timeZone: 'timeZone',
datasourceInstance: undefined,
setLogsSampleEnabled: jest.fn(),
queries: [],
splitOpen: jest.fn(),
};
return { ...props, ...propOverrides };
@ -100,4 +110,32 @@ describe('LogsSamplePanel', () => {
expect(screen.getByText('Failed to load logs sample for this query')).toBeInTheDocument();
expect(screen.getByText('Test error message')).toBeInTheDocument();
});
it('has split open button functionality', async () => {
const datasourceInstance = {
uid: 'test_uid',
getDataProvider: jest.fn(),
getSupportedSupplementaryQueryTypes: jest.fn().mockImplementation(() => [SupplementaryQueryType.LogsSample]),
getSupplementaryQuery: jest.fn().mockImplementation(() => {
return {
refId: 'test_refid',
} as DataQuery;
}),
} as unknown as DataSourceApi;
const splitOpen = jest.fn();
render(
<LogsSamplePanel
{...createProps({
queries: [{ refId: 'test_refid' }],
queryResponse: { data: [sampleDataFrame], state: LoadingState.Done },
splitOpen,
datasourceInstance,
})}
/>
);
const splitButton = screen.getByText('Open logs in split view');
expect(splitButton).toBeInTheDocument();
await userEvent.click(splitButton);
expect(splitOpen).toHaveBeenCalledWith({ datasourceUid: 'test_uid', query: { refId: 'test_refid' } });
});
});

@ -1,9 +1,19 @@
import { css } from '@emotion/css';
import React from 'react';
import { DataQueryResponse, DataSourceApi, LoadingState, LogsDedupStrategy } from '@grafana/data';
import {
DataQueryResponse,
DataSourceApi,
GrafanaTheme2,
hasSupplementaryQuerySupport,
LoadingState,
LogsDedupStrategy,
SplitOpen,
SupplementaryQueryType,
} from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema';
import { Collapse } from '@grafana/ui';
import { TimeZone, DataQuery } from '@grafana/schema';
import { Button, Collapse, useStyles2 } from '@grafana/ui';
import { dataFrameToLogsModel } from 'app/core/logsModel';
import store from 'app/core/store';
@ -16,13 +26,16 @@ type Props = {
queryResponse: DataQueryResponse | undefined;
enabled: boolean;
timeZone: TimeZone;
queries: DataQuery[];
datasourceInstance: DataSourceApi | null | undefined;
splitOpen: SplitOpen;
setLogsSampleEnabled: (enabled: boolean) => void;
};
export function LogsSamplePanel(props: Props) {
const { queryResponse, timeZone, enabled, setLogsSampleEnabled, datasourceInstance } = props;
const { queryResponse, timeZone, enabled, setLogsSampleEnabled, datasourceInstance, queries, splitOpen } = props;
const styles = useStyles2(getStyles);
const onToggleLogsSampleCollapse = (isOpen: boolean) => {
setLogsSampleEnabled(isOpen);
reportInteraction('grafana_explore_logs_sample_toggle_clicked', {
@ -31,6 +44,32 @@ export function LogsSamplePanel(props: Props) {
});
};
const OpenInSplitViewButton = () => {
if (!hasSupplementaryQuerySupport(datasourceInstance, SupplementaryQueryType.LogsSample)) {
return null;
}
const logSampleQueries = queries
.map((query) => datasourceInstance.getSupplementaryQuery(SupplementaryQueryType.LogsSample, query))
.filter((query): query is DataQuery => !!query);
if (!logSampleQueries.length) {
return null;
}
return (
<Button
size="sm"
className={styles.logSamplesButton}
// TODO: support multiple queries (#62107)
// This currently works only for the first query as splitOpen supports only 1 query
onClick={() => splitOpen({ query: logSampleQueries[0], datasourceUid: datasourceInstance.uid })}
>
Open logs in split view
</Button>
);
};
let LogsSamplePanelContent: JSX.Element | null;
if (queryResponse === undefined) {
@ -46,16 +85,19 @@ export function LogsSamplePanel(props: Props) {
} else {
const logs = dataFrameToLogsModel(queryResponse.data);
LogsSamplePanelContent = (
<LogRows
logRows={logs.rows}
dedupStrategy={LogsDedupStrategy.none}
showLabels={store.getBool(SETTINGS_KEYS.showLabels, false)}
showTime={store.getBool(SETTINGS_KEYS.showTime, true)}
wrapLogMessage={store.getBool(SETTINGS_KEYS.wrapLogMessage, true)}
prettifyLogMessage={store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)}
timeZone={timeZone}
enableLogDetails={true}
/>
<>
<OpenInSplitViewButton />
<LogRows
logRows={logs.rows}
dedupStrategy={LogsDedupStrategy.none}
showLabels={store.getBool(SETTINGS_KEYS.showLabels, false)}
showTime={store.getBool(SETTINGS_KEYS.showTime, true)}
wrapLogMessage={store.getBool(SETTINGS_KEYS.wrapLogMessage, true)}
prettifyLogMessage={store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)}
timeZone={timeZone}
enableLogDetails={true}
/>
</>
);
}
@ -65,3 +107,11 @@ export function LogsSamplePanel(props: Props) {
</Collapse>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
logSamplesButton: css`
position: absolute;
top: ${theme.spacing(1)};
right: ${theme.spacing(1)}; ;
`,
});

@ -27,6 +27,7 @@ export const createDefaultInitialState = () => {
getSupportedSupplementaryQueryTypes: jest
.fn()
.mockImplementation(() => [SupplementaryQueryType.LogsVolume, SupplementaryQueryType.LogsSample]),
getSupplementaryQuery: jest.fn(),
meta: {
id: 'something',
},

@ -413,6 +413,7 @@ describe('reducer', () => {
SupplementaryQueryType.LogsVolume,
SupplementaryQueryType.LogsSample,
],
getSupplementaryQuery: jest.fn(),
},
},
},

@ -15,6 +15,7 @@ import {
FieldType,
MutableDataFrame,
RawTimeRange,
SupplementaryQueryType,
TimeRange,
toUtc,
} from '@grafana/data';
@ -924,6 +925,56 @@ describe('ElasticDatasource', () => {
expect((interpolatedQuery.bucketAggs![0] as Filters).settings!.filters![0].query).toBe('*');
});
describe('getSupplementaryQuery', () => {
let ds: ElasticDatasource;
beforeEach(() => {
ds = getTestContext().ds;
});
it('does not return logs volume query for metric query', () => {
expect(
ds.getSupplementaryQuery(SupplementaryQueryType.LogsVolume, {
refId: 'A',
metrics: [{ type: 'count', id: '1' }],
bucketAggs: [{ type: 'filters', settings: { filters: [{ query: 'foo', label: '' }] }, id: '1' }],
query: 'foo="bar"',
})
).toEqual(undefined);
});
it('returns logs volume query for log query', () => {
expect(
ds.getSupplementaryQuery(SupplementaryQueryType.LogsVolume, {
refId: 'A',
metrics: [{ type: 'logs', id: '1' }],
query: 'foo="bar"',
})
).toEqual({
bucketAggs: [
{
field: '',
id: '3',
settings: {
interval: 'auto',
min_doc_count: '0',
trimEdges: '0',
},
type: 'date_histogram',
},
],
metrics: [
{
id: '1',
type: 'count',
},
],
query: 'foo="bar"',
refId: 'log-volume-A',
timeField: '',
});
});
});
});
describe('getMultiSearchUrl', () => {

@ -600,58 +600,80 @@ export class ElasticDatasource
return [SupplementaryQueryType.LogsVolume];
}
getLogsVolumeDataProvider(request: DataQueryRequest<ElasticsearchQuery>): Observable<DataQueryResponse> | undefined {
const isLogsVolumeAvailable = request.targets.some((target) => {
return target.metrics?.length === 1 && target.metrics[0].type === 'logs';
});
if (!isLogsVolumeAvailable) {
getSupplementaryQuery(type: SupplementaryQueryType, query: ElasticsearchQuery): ElasticsearchQuery | undefined {
if (!this.getSupportedSupplementaryQueryTypes().includes(type)) {
return undefined;
}
const logsVolumeRequest = cloneDeep(request);
logsVolumeRequest.targets = logsVolumeRequest.targets.map((target) => {
const bucketAggs: BucketAggregation[] = [];
const timeField = this.timeField ?? '@timestamp';
if (this.logLevelField) {
let isQuerySuitable = false;
switch (type) {
case SupplementaryQueryType.LogsVolume:
// it has to be a logs-producing range-query
isQuerySuitable = !!(query.metrics?.length === 1 && query.metrics[0].type === 'logs');
if (!isQuerySuitable) {
return undefined;
}
const bucketAggs: BucketAggregation[] = [];
const timeField = this.timeField ?? '@timestamp';
if (this.logLevelField) {
bucketAggs.push({
id: '2',
type: 'terms',
settings: {
min_doc_count: '0',
size: '0',
order: 'desc',
orderBy: '_count',
missing: LogLevel.unknown,
},
field: this.logLevelField,
});
}
bucketAggs.push({
id: '2',
type: 'terms',
id: '3',
type: 'date_histogram',
settings: {
interval: 'auto',
min_doc_count: '0',
size: '0',
order: 'desc',
orderBy: '_count',
missing: LogLevel.unknown,
trimEdges: '0',
},
field: this.logLevelField,
field: timeField,
});
}
bucketAggs.push({
id: '3',
type: 'date_histogram',
settings: {
interval: 'auto',
min_doc_count: '0',
trimEdges: '0',
},
field: timeField,
});
const logsVolumeQuery: ElasticsearchQuery = {
refId: `${REF_ID_STARTER_LOG_VOLUME}${target.refId}`,
query: target.query,
metrics: [{ type: 'count', id: '1' }],
timeField,
bucketAggs,
};
return logsVolumeQuery;
});
return {
refId: `${REF_ID_STARTER_LOG_VOLUME}${query.refId}`,
query: query.query,
metrics: [{ type: 'count', id: '1' }],
timeField,
bucketAggs,
};
return queryLogsVolume(this, logsVolumeRequest, {
range: request.range,
targets: request.targets,
extractLevel: (dataFrame) => getLogLevelFromKey(dataFrame.name || ''),
});
default:
return undefined;
}
}
getLogsVolumeDataProvider(request: DataQueryRequest<ElasticsearchQuery>): Observable<DataQueryResponse> | undefined {
const logsVolumeRequest = cloneDeep(request);
const targets = logsVolumeRequest.targets
.map((target) => this.getSupplementaryQuery(SupplementaryQueryType.LogsVolume, target))
.filter((query): query is ElasticsearchQuery => !!query);
if (!targets.length) {
return undefined;
}
return queryLogsVolume(
this,
{ ...logsVolumeRequest, targets },
{
range: request.range,
targets: request.targets,
extractLevel: (dataFrame) => getLogLevelFromKey(dataFrame.name || ''),
}
);
}
query(request: DataQueryRequest<ElasticsearchQuery>): Observable<DataQueryResponse> {

@ -894,7 +894,7 @@ describe('LokiDatasource', () => {
it('creates provider for logs query', () => {
const options = getQueryOptions<LokiQuery>({
targets: [{ expr: '{label=value}', refId: 'A' }],
targets: [{ expr: '{label=value}', refId: 'A', queryType: LokiQueryType.Range }],
});
expect(ds.getDataProvider(SupplementaryQueryType.LogsVolume, options)).toBeDefined();
@ -911,8 +911,8 @@ describe('LokiDatasource', () => {
it('creates provider if at least one query is a logs query', () => {
const options = getQueryOptions<LokiQuery>({
targets: [
{ expr: 'rate({label=value}[1m])', refId: 'A' },
{ expr: '{label=value}', refId: 'B' },
{ expr: 'rate({label=value}[1m])', queryType: LokiQueryType.Range, refId: 'A' },
{ expr: '{label=value}', queryType: LokiQueryType.Range, refId: 'B' },
],
});
@ -962,6 +962,93 @@ describe('LokiDatasource', () => {
});
});
describe('getSupplementaryQuery', () => {
let ds: LokiDatasource;
beforeEach(() => {
ds = createLokiDatasource(templateSrvStub);
});
describe('logs volume', () => {
it('returns logs volume query for range log query', () => {
expect(
ds.getSupplementaryQuery(SupplementaryQueryType.LogsVolume, {
expr: '{label=value}',
queryType: LokiQueryType.Range,
refId: 'A',
})
).toEqual({
expr: 'sum by (level) (count_over_time({label=value}[$__interval]))',
instant: false,
queryType: 'range',
refId: 'log-volume-A',
volumeQuery: true,
});
});
it('does not return logs volume query for instant log query', () => {
expect(
ds.getSupplementaryQuery(SupplementaryQueryType.LogsVolume, {
expr: '{label=value}',
queryType: LokiQueryType.Instant,
refId: 'A',
})
).toEqual(undefined);
});
it('does not return logs volume query for metric query', () => {
expect(
ds.getSupplementaryQuery(SupplementaryQueryType.LogsVolume, {
expr: 'rate({label=value}[5m]',
queryType: LokiQueryType.Range,
refId: 'A',
})
).toEqual(undefined);
});
});
describe('logs sample', () => {
it('returns logs sample query for range metric query', () => {
expect(
ds.getSupplementaryQuery(SupplementaryQueryType.LogsSample, {
expr: 'rate({label=value}[5m]',
queryType: LokiQueryType.Range,
refId: 'A',
})
).toEqual({
expr: '{label=value}',
queryType: 'range',
refId: 'log-sample-A',
maxLines: 100,
});
});
it('returns logs sample query for instant metric query', () => {
expect(
ds.getSupplementaryQuery(SupplementaryQueryType.LogsSample, {
expr: 'rate({label=value}[5m]',
queryType: LokiQueryType.Instant,
refId: 'A',
})
).toEqual({
expr: '{label=value}',
queryType: 'instant',
refId: 'log-sample-A',
maxLines: 100,
});
});
it('does not return logs sample query for log query query', () => {
expect(
ds.getSupplementaryQuery(SupplementaryQueryType.LogsSample, {
expr: '{label=value}',
queryType: LokiQueryType.Range,
refId: 'A',
})
).toEqual(undefined);
});
});
});
describe('importing queries', () => {
let ds: LokiDatasource;
beforeEach(() => {

@ -161,62 +161,80 @@ export class LokiDatasource
return [SupplementaryQueryType.LogsVolume, SupplementaryQueryType.LogsSample];
}
getLogsVolumeDataProvider(request: DataQueryRequest<LokiQuery>): Observable<DataQueryResponse> | undefined {
const isQuerySuitable = (query: LokiQuery) => {
const normalized = getNormalizedLokiQuery(query);
const { expr } = normalized;
// it has to be a logs-producing range-query
return expr && isLogsQuery(expr) && normalized.queryType === LokiQueryType.Range;
};
const isLogsVolumeAvailable = request.targets.some(isQuerySuitable);
if (!isLogsVolumeAvailable) {
getSupplementaryQuery(type: SupplementaryQueryType, query: LokiQuery): LokiQuery | undefined {
if (!this.getSupportedSupplementaryQueryTypes().includes(type)) {
return undefined;
}
const logsVolumeRequest = cloneDeep(request);
logsVolumeRequest.targets = logsVolumeRequest.targets.filter(isQuerySuitable).map((target) => {
const query = removeCommentsFromQuery(target.expr);
return {
...target,
refId: `${REF_ID_STARTER_LOG_VOLUME}${target.refId}`,
instant: false,
volumeQuery: true,
expr: `sum by (level) (count_over_time(${query}[$__interval]))`,
};
});
const normalizedQuery = getNormalizedLokiQuery(query);
const expr = removeCommentsFromQuery(normalizedQuery.expr);
let isQuerySuitable = false;
return queryLogsVolume(this, logsVolumeRequest, {
extractLevel,
range: request.range,
targets: request.targets,
});
}
switch (type) {
case SupplementaryQueryType.LogsVolume:
// it has to be a logs-producing range-query
isQuerySuitable = !!(query.expr && isLogsQuery(query.expr) && query.queryType === LokiQueryType.Range);
if (!isQuerySuitable) {
return undefined;
}
getLogsSampleDataProvider(request: DataQueryRequest<LokiQuery>): Observable<DataQueryResponse> | undefined {
const isQuerySuitable = (query: LokiQuery) => {
return query.expr && !isLogsQuery(query.expr);
};
return {
...normalizedQuery,
refId: `${REF_ID_STARTER_LOG_VOLUME}${normalizedQuery.refId}`,
instant: false,
volumeQuery: true,
expr: `sum by (level) (count_over_time(${expr}[$__interval]))`,
};
case SupplementaryQueryType.LogsSample:
// it has to be a metric query
isQuerySuitable = !!(query.expr && !isLogsQuery(query.expr));
if (!isQuerySuitable) {
return undefined;
}
return {
...normalizedQuery,
refId: `${REF_ID_STARTER_LOG_SAMPLE}${normalizedQuery.refId}`,
expr: getLogQueryFromMetricsQuery(expr),
maxLines: 100,
};
const isLogsSampleAvailable = request.targets.some(isQuerySuitable);
default:
return undefined;
}
}
if (!isLogsSampleAvailable) {
getLogsVolumeDataProvider(request: DataQueryRequest<LokiQuery>): Observable<DataQueryResponse> | undefined {
const logsVolumeRequest = cloneDeep(request);
const targets = logsVolumeRequest.targets
.map((query) => this.getSupplementaryQuery(SupplementaryQueryType.LogsVolume, query))
.filter((query): query is LokiQuery => !!query);
if (!targets.length) {
return undefined;
}
return queryLogsVolume(
this,
{ ...logsVolumeRequest, targets },
{
extractLevel,
range: request.range,
targets: request.targets,
}
);
}
getLogsSampleDataProvider(request: DataQueryRequest<LokiQuery>): Observable<DataQueryResponse> | undefined {
const logsSampleRequest = cloneDeep(request);
logsSampleRequest.targets = logsSampleRequest.targets.filter(isQuerySuitable).map((target) => {
const query = removeCommentsFromQuery(target.expr);
return {
...target,
refId: `${REF_ID_STARTER_LOG_SAMPLE}${target.refId}`,
expr: getLogQueryFromMetricsQuery(query),
maxLines: 100,
};
});
const targets = logsSampleRequest.targets
.map((query) => this.getSupplementaryQuery(SupplementaryQueryType.LogsSample, query))
.filter((query): query is LokiQuery => !!query);
return queryLogsSample(this, logsSampleRequest);
if (!targets.length) {
return undefined;
}
return queryLogsSample(this, { ...logsSampleRequest, targets });
}
query(request: DataQueryRequest<LokiQuery>): Observable<DataQueryResponse> {

Loading…
Cancel
Save