diff --git a/packages/grafana-data/src/types/logs.ts b/packages/grafana-data/src/types/logs.ts index 714a56acf9d..6469be8404a 100644 --- a/packages/grafana-data/src/types/logs.ts +++ b/packages/grafana-data/src/types/logs.ts @@ -195,11 +195,23 @@ export enum SupplementaryQueryType { * @internal */ export interface DataSourceWithSupplementaryQueriesSupport { + /** + * 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 ): Observable | 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 = ( @@ -214,6 +226,7 @@ export const hasSupplementaryQuerySupport = ( return ( withSupplementaryQueriesSupport.getDataProvider !== undefined && + withSupplementaryQueriesSupport.getSupplementaryQuery !== undefined && withSupplementaryQueriesSupport.getSupportedSupplementaryQueryTypes().includes(type) ); }; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 0a0f2443d9e..b99eba23dc6 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -367,15 +367,17 @@ export class Explore extends React.PureComponent { } renderLogsSamplePanel() { - const { logsSample, timeZone, setSupplementaryQueryEnabled, exploreId, datasourceInstance } = this.props; + const { logsSample, timeZone, setSupplementaryQueryEnabled, exploreId, datasourceInstance, queries } = this.props; return ( + splitOpen={this.onSplitOpen('logsSample')} + setLogsSampleEnabled={(enabled: boolean) => setSupplementaryQueryEnabled(exploreId, enabled, SupplementaryQueryType.LogsSample) } /> diff --git a/public/app/features/explore/LogsSample.test.tsx b/public/app/features/explore/LogsSample.test.tsx index 967457fdb86..158a062f2da 100644 --- a/public/app/features/explore/LogsSample.test.tsx +++ b/public/app/features/explore/LogsSample.test.tsx @@ -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 { 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( + + ); + 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' } }); + }); }); diff --git a/public/app/features/explore/LogsSamplePanel.tsx b/public/app/features/explore/LogsSamplePanel.tsx index 72dbc4d8bc0..8d9d0cf949c 100644 --- a/public/app/features/explore/LogsSamplePanel.tsx +++ b/public/app/features/explore/LogsSamplePanel.tsx @@ -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 ( + + ); + }; + let LogsSamplePanelContent: JSX.Element | null; if (queryResponse === undefined) { @@ -46,16 +85,19 @@ export function LogsSamplePanel(props: Props) { } else { const logs = dataFrameToLogsModel(queryResponse.data); LogsSamplePanelContent = ( - + <> + + + ); } @@ -65,3 +107,11 @@ export function LogsSamplePanel(props: Props) { ); } + +const getStyles = (theme: GrafanaTheme2) => ({ + logSamplesButton: css` + position: absolute; + top: ${theme.spacing(1)}; + right: ${theme.spacing(1)}; ; + `, +}); diff --git a/public/app/features/explore/state/helpers.ts b/public/app/features/explore/state/helpers.ts index fe3fdba14c6..511e24acc81 100644 --- a/public/app/features/explore/state/helpers.ts +++ b/public/app/features/explore/state/helpers.ts @@ -27,6 +27,7 @@ export const createDefaultInitialState = () => { getSupportedSupplementaryQueryTypes: jest .fn() .mockImplementation(() => [SupplementaryQueryType.LogsVolume, SupplementaryQueryType.LogsSample]), + getSupplementaryQuery: jest.fn(), meta: { id: 'something', }, diff --git a/public/app/features/explore/state/query.test.ts b/public/app/features/explore/state/query.test.ts index a75697284da..4c461a0fd78 100644 --- a/public/app/features/explore/state/query.test.ts +++ b/public/app/features/explore/state/query.test.ts @@ -413,6 +413,7 @@ describe('reducer', () => { SupplementaryQueryType.LogsVolume, SupplementaryQueryType.LogsSample, ], + getSupplementaryQuery: jest.fn(), }, }, }, diff --git a/public/app/plugins/datasource/elasticsearch/datasource.test.ts b/public/app/plugins/datasource/elasticsearch/datasource.test.ts index f5b349c0157..44be841ff29 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.test.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.test.ts @@ -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', () => { diff --git a/public/app/plugins/datasource/elasticsearch/datasource.ts b/public/app/plugins/datasource/elasticsearch/datasource.ts index dfa3f5eeecb..dcdd8e15a8f 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.ts @@ -600,58 +600,80 @@ export class ElasticDatasource return [SupplementaryQueryType.LogsVolume]; } - getLogsVolumeDataProvider(request: DataQueryRequest): Observable | 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, }); + + return { + refId: `${REF_ID_STARTER_LOG_VOLUME}${query.refId}`, + query: query.query, + metrics: [{ type: 'count', id: '1' }], + timeField, + bucketAggs, + }; + + default: + return undefined; + } + } + + getLogsVolumeDataProvider(request: DataQueryRequest): Observable | 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 || ''), } - 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 queryLogsVolume(this, logsVolumeRequest, { - range: request.range, - targets: request.targets, - extractLevel: (dataFrame) => getLogLevelFromKey(dataFrame.name || ''), - }); + ); } query(request: DataQueryRequest): Observable { diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts index c902aaca82f..5e183049033 100644 --- a/public/app/plugins/datasource/loki/datasource.test.ts +++ b/public/app/plugins/datasource/loki/datasource.test.ts @@ -894,7 +894,7 @@ describe('LokiDatasource', () => { it('creates provider for logs query', () => { const options = getQueryOptions({ - 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({ 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(() => { diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index a88ef2a63d1..4d28ff4e299 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -161,62 +161,80 @@ export class LokiDatasource return [SupplementaryQueryType.LogsVolume, SupplementaryQueryType.LogsSample]; } - getLogsVolumeDataProvider(request: DataQueryRequest): Observable | 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; + } + + 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, + }; + + default: + return undefined; + } + } + + getLogsVolumeDataProvider(request: DataQueryRequest): Observable | 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): Observable | undefined { - const isQuerySuitable = (query: LokiQuery) => { - return query.expr && !isLogsQuery(query.expr); - }; + const logsSampleRequest = cloneDeep(request); + const targets = logsSampleRequest.targets + .map((query) => this.getSupplementaryQuery(SupplementaryQueryType.LogsSample, query)) + .filter((query): query is LokiQuery => !!query); - const isLogsSampleAvailable = request.targets.some(isQuerySuitable); - - if (!isLogsSampleAvailable) { + if (!targets.length) { return 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, - }; - }); - - return queryLogsSample(this, logsSampleRequest); + return queryLogsSample(this, { ...logsSampleRequest, targets }); } query(request: DataQueryRequest): Observable {