Loki: Full range logs volume (#39327)

* Basic implementation of getLogsVolumeQuery method

* Add todos

* Add a switcher to automatically load logs volume

* De-scope dismissing logs volume panel

* De-scope logs volume query cancellation

* Remove todo

* Aggregate logs volume components in single panel

* Show logs volume only when it's available

* Aggregate logs volume by level

* Simplify aggregation

* Handle no logs volume data

* Add error handling

* Do not show auto-load logs volume switcher when loading logs volume is not available

* Remove old logs volume graph

* Clean up

* Make getting data provider more generic

* Provide complete logs volume data (error, isLoading)

* Display more specific error message

* Add missing props to mocks

* Remove setRequest method

* Mark getQueryRelatedDataProviders as internal

* Add missing dataQueryRequest and add a todo

* Remove redundant loading state

* Do not mutate existing queries

* Apply fix for zooming-in from main

* Post-merge fixes

* Create collection for data provider results

* Use more generic names

* Move aggregation logic to Loki logs volume provider

* Move LogsVolume to common types

* Update tests

* Post-merge fixes

* Fix mapping related data values

* Simplify prop mappings

* Add docs

* Fix property name

* Clean-up

* Mark new types as internal

* Reduce number of providers to logs volume only

* Simplify data structure to DataQueryResponse

* Move Logs Volume panel to a separate component

* Test logsVolumeProvider.ts

* Add observable version of datasource mock

* Test getLogsVolumeDataProvider method

* Test LogsVolumePanel

* Test logs volume reducer

* Clean up

* Clean up

* Fix test

* Use sum by to use level field directly

* Fix strict type errors

* Fix strict type errors

* Use "logs" instead of "unknown" if only one level was detected

* Add docs about logs volume

* Rename histogramRequest to logsVolumeRequest

* Use LogsVolumeContentWrapper all content types

* Move `autoLoadLogsVolume` local storage handling

* Fix strict error

* Move getting autoLoadLogsVolume to initial state

* Cancel current logs volume subscription

* Test cancelling subscriptions

* Update docs/sources/datasources/loki.md

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>

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

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>

* Inline container styles

* Ensure logs volume is aggregated per each subscription separately

* Simplify logs volume provider

* Type-guard support for logs volume provider

* Simplify event handlers to avoid casting

* Clean up and docs

* Move auto-load switcher to logs volume panel

* Fix test

* Move DataSourceWithLogsVolumeSupport to avoid cross referencing

* Simplify interface

* Bring back old histogram and hide the new one behind a feature flag

* Add missing props to logs histogram panel

* Clean up the provider when it's not supported

* Simplify storing autoLoadLogsVolume

* Remove docs

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

Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com>

* Skip dataframes without fields (instant queries)

* Revert styles changes

* Revert styles changes

* Add release tag

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>
Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com>
pull/39862/head
Piotr Jamróz 4 years ago committed by GitHub
parent b7a68a9516
commit 124e9daf26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      packages/grafana-data/src/types/index.ts
  2. 22
      packages/grafana-data/src/types/logsVolume.ts
  3. 1
      packages/grafana-runtime/src/config.ts
  4. 5
      public/app/features/explore/Explore.test.tsx
  5. 57
      public/app/features/explore/Explore.tsx
  6. 3
      public/app/features/explore/ExploreToolbar.tsx
  7. 47
      public/app/features/explore/Logs.tsx
  8. 56
      public/app/features/explore/LogsVolumePanel.test.tsx
  9. 114
      public/app/features/explore/LogsVolumePanel.tsx
  10. 1
      public/app/features/explore/QueryRows.test.tsx
  11. 18
      public/app/features/explore/Wrapper.tsx
  12. 18
      public/app/features/explore/state/main.ts
  13. 176
      public/app/features/explore/state/query.test.ts
  14. 148
      public/app/features/explore/state/query.ts
  15. 4
      public/app/features/explore/state/utils.ts
  16. 17
      public/app/features/explore/utils/decorators.ts
  17. 107
      public/app/plugins/datasource/loki/dataProviders/logsVolumeProvider.test.ts
  18. 175
      public/app/plugins/datasource/loki/dataProviders/logsVolumeProvider.ts
  19. 32
      public/app/plugins/datasource/loki/datasource.test.ts
  20. 13
      public/app/plugins/datasource/loki/datasource.ts
  21. 14
      public/app/types/explore.ts
  22. 32
      public/test/mocks/datasource_srv.ts

@ -7,6 +7,7 @@ export * from './dashboard';
export * from './query';
export * from './annotations';
export * from './logs';
export * from './logsVolume';
export * from './navModel';
export * from './select';
export * from './time';

@ -0,0 +1,22 @@
import { DataQuery } from './query';
import { DataQueryRequest, DataQueryResponse } from './datasource';
import { Observable } from 'rxjs';
/**
* TODO: This should be added to ./logs.ts but because of cross reference between ./datasource.ts and ./logs.ts it can
* be done only after decoupling "logs" from "datasource" (https://github.com/grafana/grafana/pull/39536)
*
* @internal
*/
export interface DataSourceWithLogsVolumeSupport<TQuery extends DataQuery> {
getLogsVolumeDataProvider(request: DataQueryRequest<TQuery>): Observable<DataQueryResponse> | undefined;
}
/**
* @internal
*/
export const hasLogsVolumeSupport = <TQuery extends DataQuery>(
datasource: any
): datasource is DataSourceWithLogsVolumeSupport<TQuery> => {
return (datasource as DataSourceWithLogsVolumeSupport<TQuery>).getLogsVolumeDataProvider !== undefined;
};

@ -67,6 +67,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
recordedQueries: false,
prometheusMonaco: false,
newNavigation: false,
fullRangeLogsVolume: false,
};
licenseInfo: LicenseInfo = {} as LicenseInfo;
rendererAvailable = false;

@ -83,6 +83,11 @@ const dummyProps: Props = {
showTrace: true,
showNodeGraph: true,
splitOpen: (() => {}) as any,
autoLoadLogsVolume: false,
logsVolumeData: undefined,
logsVolumeDataProvider: undefined,
loadLogsVolumeData: () => {},
changeAutoLogsVolume: () => {},
};
describe('Explore', () => {

@ -5,8 +5,8 @@ import { connect, ConnectedProps } from 'react-redux';
import AutoSizer from 'react-virtualized-auto-sizer';
import memoizeOne from 'memoize-one';
import { selectors } from '@grafana/e2e-selectors';
import { ErrorBoundaryAlert, CustomScrollbar, Collapse, withTheme2, Themeable2 } from '@grafana/ui';
import { AbsoluteTimeRange, DataQuery, LoadingState, RawTimeRange, DataFrame, GrafanaTheme2 } from '@grafana/data';
import { Collapse, CustomScrollbar, ErrorBoundaryAlert, Themeable2, withTheme2 } from '@grafana/ui';
import { AbsoluteTimeRange, DataFrame, DataQuery, GrafanaTheme2, LoadingState, RawTimeRange } from '@grafana/data';
import LogsContainer from './LogsContainer';
import { QueryRows } from './QueryRows';
@ -16,7 +16,15 @@ import ExploreQueryInspector from './ExploreQueryInspector';
import { splitOpen } from './state/main';
import { changeSize } from './state/explorePane';
import { updateTimeRange } from './state/time';
import { scanStopAction, addQueryRow, modifyQueries, setQueries, scanStart } from './state/query';
import {
addQueryRow,
changeAutoLogsVolume,
loadLogsVolumeData,
modifyQueries,
scanStart,
scanStopAction,
setQueries,
} from './state/query';
import { ExploreId, ExploreItemState } from 'app/types/explore';
import { StoreState } from 'app/types';
import { ExploreToolbar } from './ExploreToolbar';
@ -28,6 +36,7 @@ import { NodeGraphContainer } from './NodeGraphContainer';
import { ResponseErrorContainer } from './ResponseErrorContainer';
import { TraceViewContainer } from './TraceView/TraceViewContainer';
import { ExploreGraph } from './ExploreGraph';
import { LogsVolumePanel } from './LogsVolumePanel';
const getStyles = (theme: GrafanaTheme2) => {
return {
@ -205,6 +214,36 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
);
}
renderLogsVolume(width: number) {
const {
logsVolumeData,
exploreId,
loadLogsVolumeData,
autoLoadLogsVolume,
changeAutoLogsVolume,
absoluteRange,
timeZone,
splitOpen,
} = this.props;
return (
<LogsVolumePanel
exploreId={exploreId}
loadLogsVolumeData={loadLogsVolumeData}
absoluteRange={absoluteRange}
width={width}
logsVolumeData={logsVolumeData}
onUpdateTimeRange={this.onUpdateTimeRange}
timeZone={timeZone}
splitOpen={splitOpen}
autoLoadLogsVolume={autoLoadLogsVolume}
onChangeAutoLogsVolume={(autoLoadLogsVolume) => {
changeAutoLogsVolume(exploreId, autoLoadLogsVolume);
}}
/>
);
}
renderTablePanel(width: number) {
const { exploreId, datasourceInstance } = this.props;
return (
@ -277,12 +316,14 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
showLogs,
showTrace,
showNodeGraph,
logsVolumeDataProvider,
} = this.props;
const { openDrawer } = this.state;
const styles = getStyles(theme);
const showPanels = queryResponse && queryResponse.state !== LoadingState.NotStarted;
const showRichHistory = openDrawer === ExploreDrawer.RichHistory;
const showQueryInspector = openDrawer === ExploreDrawer.QueryInspector;
const showLogsVolume = !!logsVolumeDataProvider;
return (
<CustomScrollbar autoHeightMin={'100%'}>
@ -319,6 +360,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
{showMetrics && graphResult && (
<ErrorBoundaryAlert>{this.renderGraphPanel(width)}</ErrorBoundaryAlert>
)}
{showLogsVolume && <ErrorBoundaryAlert>{this.renderLogsVolume(width)}</ErrorBoundaryAlert>}
{showTable && <ErrorBoundaryAlert>{this.renderTablePanel(width)}</ErrorBoundaryAlert>}
{showLogs && <ErrorBoundaryAlert>{this.renderLogsPanel(width)}</ErrorBoundaryAlert>}
{showNodeGraph && <ErrorBoundaryAlert>{this.renderNodeGraphPanel()}</ErrorBoundaryAlert>}
@ -353,7 +395,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
const explore = state.explore;
const { syncedTimes } = explore;
const { syncedTimes, autoLoadLogsVolume } = explore;
const item: ExploreItemState = explore[exploreId]!;
const timeZone = getTimeZone(state.user);
const {
@ -362,6 +404,8 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
queryKeys,
isLive,
graphResult,
logsVolumeDataProvider,
logsVolumeData,
logsResult,
showLogs,
showMetrics,
@ -379,6 +423,9 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
queryKeys,
isLive,
graphResult,
autoLoadLogsVolume,
logsVolumeDataProvider,
logsVolumeData,
logsResult: logsResult ?? undefined,
absoluteRange,
queryResponse,
@ -400,6 +447,8 @@ const mapDispatchToProps = {
scanStopAction,
setQueries,
updateTimeRange,
loadLogsVolumeData,
changeAutoLogsVolume,
addQueryRow,
splitOpen,
};

@ -213,7 +213,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props> {
}
const mapStateToProps = (state: StoreState, { exploreId }: OwnProps) => {
const syncedTimes = state.explore.syncedTimes;
const { syncedTimes, autoLoadLogsVolume } = state.explore;
const exploreItem: ExploreItemState = state.explore[exploreId]!;
const {
datasourceInstance,
@ -242,6 +242,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps) => {
isPaused,
syncedTimes,
containerWidth,
autoLoadLogsVolume,
};
};

@ -136,8 +136,8 @@ export class UnthemedLogs extends PureComponent<Props, State> {
this.setState({ dedupStrategy });
};
onChangeLabels = (event?: React.SyntheticEvent) => {
const target = event && (event.target as HTMLInputElement);
onChangeLabels = (event: React.ChangeEvent<HTMLInputElement>) => {
const { target } = event;
if (target) {
const showLabels = target.checked;
this.setState({
@ -147,8 +147,8 @@ export class UnthemedLogs extends PureComponent<Props, State> {
}
};
onChangeTime = (event?: React.SyntheticEvent) => {
const target = event && (event.target as HTMLInputElement);
onChangeTime = (event: React.ChangeEvent<HTMLInputElement>) => {
const { target } = event;
if (target) {
const showTime = target.checked;
this.setState({
@ -158,8 +158,8 @@ export class UnthemedLogs extends PureComponent<Props, State> {
}
};
onChangewrapLogMessage = (event?: React.SyntheticEvent) => {
const target = event && (event.target as HTMLInputElement);
onChangewrapLogMessage = (event: React.ChangeEvent<HTMLInputElement>) => {
const { target } = event;
if (target) {
const wrapLogMessage = target.checked;
this.setState({
@ -169,8 +169,8 @@ export class UnthemedLogs extends PureComponent<Props, State> {
}
};
onChangePrettifyLogMessage = (event?: React.SyntheticEvent) => {
const target = event && (event.target as HTMLInputElement);
onChangePrettifyLogMessage = (event: React.ChangeEvent<HTMLInputElement>) => {
const { target } = event;
if (target) {
const prettifyLogMessage = target.checked;
this.setState({
@ -294,21 +294,24 @@ export class UnthemedLogs extends PureComponent<Props, State> {
return (
<>
<div className={styles.infoText}>
This datasource does not support full-range histograms. The graph is based on the logs seen in the response.
</div>
{logsSeries && logsSeries.length ? (
<ExploreGraph
data={logsSeries}
height={150}
width={width}
tooltipDisplayMode={TooltipDisplayMode.Multi}
absoluteRange={visibleRange || absoluteRange}
timeZone={timeZone}
loadingState={loadingState}
onChangeTime={onChangeTime}
onHiddenSeriesChanged={this.onToggleLogLevel}
/>
<>
<div className={styles.infoText}>
This datasource does not support full-range histograms. The graph is based on the logs seen in the
response.
</div>
<ExploreGraph
data={logsSeries}
height={150}
width={width}
tooltipDisplayMode={TooltipDisplayMode.Multi}
absoluteRange={visibleRange || absoluteRange}
timeZone={timeZone}
loadingState={loadingState}
onChangeTime={onChangeTime}
onHiddenSeriesChanged={this.onToggleLogLevel}
/>
</>
) : undefined}
<div className={styles.logOptions} ref={this.topLogsRef}>
<InlineFieldRow>

@ -0,0 +1,56 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { LogsVolumePanel } from './LogsVolumePanel';
import { ExploreId } from '../../types';
import { DataQueryResponse, LoadingState } from '@grafana/data';
jest.mock('./ExploreGraph', () => {
const ExploreGraph = () => <span>ExploreGraph</span>;
return {
ExploreGraph,
};
});
function renderPanel(logsVolumeData?: DataQueryResponse) {
render(
<LogsVolumePanel
exploreId={ExploreId.left}
loadLogsVolumeData={() => {}}
absoluteRange={{ from: 0, to: 1 }}
timeZone="timeZone"
splitOpen={() => {}}
width={100}
onUpdateTimeRange={() => {}}
logsVolumeData={logsVolumeData}
autoLoadLogsVolume={false}
onChangeAutoLogsVolume={() => {}}
/>
);
}
describe('LogsVolumePanel', () => {
it('shows loading message', () => {
renderPanel({ state: LoadingState.Loading, error: undefined, data: [] });
expect(screen.getByText('Logs volume is loading...')).toBeInTheDocument();
});
it('shows no volume data', () => {
renderPanel({ state: LoadingState.Done, error: undefined, data: [] });
expect(screen.getByText('No volume data.')).toBeInTheDocument();
});
it('renders logs volume histogram graph', () => {
renderPanel({ state: LoadingState.Done, error: undefined, data: [{}] });
expect(screen.getByText('ExploreGraph')).toBeInTheDocument();
});
it('shows error message', () => {
renderPanel({ state: LoadingState.Error, error: { data: { message: 'Error message' } }, data: [] });
expect(screen.getByText('Failed to load volume logs for this query: Error message')).toBeInTheDocument();
});
it('shows button to load logs volume', () => {
renderPanel(undefined);
expect(screen.getByText('Load logs volume')).toBeInTheDocument();
});
});

@ -0,0 +1,114 @@
import { AbsoluteTimeRange, DataQueryResponse, LoadingState, SplitOpen, TimeZone } from '@grafana/data';
import { Button, Collapse, InlineField, InlineFieldRow, InlineSwitch, useTheme2 } from '@grafana/ui';
import { ExploreGraph } from './ExploreGraph';
import React, { useCallback } from 'react';
import { ExploreId } from '../../types';
import { css } from '@emotion/css';
type Props = {
exploreId: ExploreId;
loadLogsVolumeData: (exploreId: ExploreId) => void;
logsVolumeData?: DataQueryResponse;
absoluteRange: AbsoluteTimeRange;
timeZone: TimeZone;
splitOpen: SplitOpen;
width: number;
onUpdateTimeRange: (timeRange: AbsoluteTimeRange) => void;
autoLoadLogsVolume: boolean;
onChangeAutoLogsVolume: (value: boolean) => void;
};
export function LogsVolumePanel(props: Props) {
const {
width,
logsVolumeData,
exploreId,
loadLogsVolumeData,
absoluteRange,
timeZone,
splitOpen,
onUpdateTimeRange,
autoLoadLogsVolume,
onChangeAutoLogsVolume,
} = props;
const theme = useTheme2();
const spacing = parseInt(theme.spacing(2).slice(0, -2), 10);
const height = 150;
let LogsVolumePanelContent;
if (!logsVolumeData) {
LogsVolumePanelContent = (
<Button
onClick={() => {
loadLogsVolumeData(exploreId);
}}
>
Load logs volume
</Button>
);
} else if (logsVolumeData?.error) {
LogsVolumePanelContent = (
<span>
Failed to load volume logs for this query:{' '}
{logsVolumeData.error.data?.message || logsVolumeData.error.statusText}
</span>
);
} else if (logsVolumeData?.state === LoadingState.Loading) {
LogsVolumePanelContent = <span>Logs volume is loading...</span>;
} else if (logsVolumeData?.data) {
if (logsVolumeData.data.length > 0) {
LogsVolumePanelContent = (
<ExploreGraph
loadingState={LoadingState.Done}
data={logsVolumeData.data}
height={height}
width={width - spacing}
absoluteRange={absoluteRange}
onChangeTime={onUpdateTimeRange}
timeZone={timeZone}
splitOpenFn={splitOpen}
/>
);
} else {
LogsVolumePanelContent = <span>No volume data.</span>;
}
}
const handleOnChangeAutoLogsVolume = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const { target } = event;
if (target) {
onChangeAutoLogsVolume(target.checked);
}
},
[onChangeAutoLogsVolume]
);
return (
<Collapse label="Logs volume" isOpen={true} loading={logsVolumeData?.state === LoadingState.Loading}>
<div
style={{ height }}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
{LogsVolumePanelContent}
</div>
<div
className={css({
display: 'flex',
justifyContent: 'end',
})}
>
<InlineFieldRow>
<InlineField label="Auto-load logs volume" transparent>
<InlineSwitch value={autoLoadLogsVolume} onChange={handleOnChangeAutoLogsVolume} transparent />
</InlineField>
</InlineFieldRow>
</div>
</Collapse>
);
}

@ -48,6 +48,7 @@ function setup(queries: DataQuery[]) {
syncedTimes: false,
right: undefined,
richHistory: [],
autoLoadLogsVolume: false,
};
const store = configureStore({ explore: initialState, user: { orgId: 1 } as UserState });

@ -2,7 +2,13 @@ import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { ExploreId, ExploreQueryParams } from 'app/types/explore';
import { ErrorBoundaryAlert } from '@grafana/ui';
import { lastSavedUrl, resetExploreAction, richHistoryUpdatedAction } from './state/main';
import {
AUTO_LOAD_LOGS_VOLUME_SETTING_KEY,
lastSavedUrl,
resetExploreAction,
richHistoryUpdatedAction,
storeAutoLoadLogsVolumeAction,
} from './state/main';
import { getRichHistory } from '../../core/utils/richHistory';
import { ExplorePaneContainer } from './ExplorePaneContainer';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
@ -10,6 +16,7 @@ import { Branding } from '../../core/components/Branding/Branding';
import { getNavModel } from '../../core/selectors/navModel';
import { StoreState } from 'app/types';
import store from '../../core/store';
interface RouteProps extends GrafanaRouteComponentProps<{}, ExploreQueryParams> {}
interface OwnProps {}
@ -18,12 +25,14 @@ const mapStateToProps = (state: StoreState) => {
return {
navModel: getNavModel(state.navIndex, 'explore'),
exploreState: state.explore,
autoLoadLogsVolume: state.explore.autoLoadLogsVolume,
};
};
const mapDispatchToProps = {
resetExploreAction,
richHistoryUpdatedAction,
storeAutoLoadLogsVolumeAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
@ -42,7 +51,8 @@ class WrapperUnconnected extends PureComponent<Props> {
this.props.richHistoryUpdatedAction({ richHistory });
}
componentDidUpdate() {
componentDidUpdate(prevProps: Props) {
const { autoLoadLogsVolume } = this.props;
const { left, right } = this.props.queryParams;
const hasSplit = Boolean(left) && Boolean(right);
const datasourceTitle = hasSplit
@ -50,6 +60,10 @@ class WrapperUnconnected extends PureComponent<Props> {
: `${this.props.exploreState.left.datasourceInstance?.name}`;
const documentTitle = `${this.props.navModel.main.text} - ${datasourceTitle} - ${Branding.AppTitle}`;
document.title = documentTitle;
if (prevProps.autoLoadLogsVolume !== autoLoadLogsVolume) {
store.set(AUTO_LOAD_LOGS_VOLUME_SETTING_KEY, autoLoadLogsVolume);
}
}
render() {

@ -9,6 +9,7 @@ import { getUrlStateFromPaneState, makeExplorePaneState } from './utils';
import { ThunkResult } from '../../../types';
import { TimeSrv } from '../../dashboard/services/TimeSrv';
import { PanelModel } from 'app/features/dashboard/state';
import store from '../../../core/store';
//
// Actions and Payloads
@ -21,6 +22,12 @@ export const syncTimesAction = createAction<SyncTimesPayload>('explore/syncTimes
export const richHistoryUpdatedAction = createAction<any>('explore/richHistoryUpdated');
/**
* Stores new value of auto-load logs volume switch. Used only internally. changeAutoLogsVolume() is used to
* update auto-load and load logs volume if it hasn't been loaded.
*/
export const storeAutoLoadLogsVolumeAction = createAction<boolean>('explore/storeAutoLoadLogsVolumeAction');
/**
* Resets state for explore.
*/
@ -154,6 +161,8 @@ export const navigateToExplore = (
};
};
export const AUTO_LOAD_LOGS_VOLUME_SETTING_KEY = 'grafana.explore.logs.autoLoadLogsVolume';
/**
* Global Explore state that handles multiple Explore areas and the split state
*/
@ -163,6 +172,7 @@ export const initialExploreState: ExploreState = {
left: initialExploreItemState,
right: undefined,
richHistory: [],
autoLoadLogsVolume: store.getBool(AUTO_LOAD_LOGS_VOLUME_SETTING_KEY, false),
};
/**
@ -217,6 +227,14 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction):
};
}
if (storeAutoLoadLogsVolumeAction.match(action)) {
const autoLoadLogsVolume = action.payload;
return {
...state,
autoLoadLogsVolume,
};
}
if (resetExploreAction.match(action)) {
const payload: ResetExplorePayload = action.payload;
const leftState = state[ExploreId.left];

@ -3,15 +3,17 @@ import {
addResultsToCache,
cancelQueries,
cancelQueriesAction,
changeAutoLogsVolume,
clearCache,
importQueries,
loadLogsVolumeData,
queryReducer,
runQueries,
scanStartAction,
scanStopAction,
} from './query';
import { ExploreId, ExploreItemState, StoreState, ThunkDispatch } from 'app/types';
import { interval, of } from 'rxjs';
import { interval, Observable, of } from 'rxjs';
import {
ArrayVector,
DataFrame,
@ -33,6 +35,16 @@ import { configureStore } from '../../../store/configureStore';
import { setTimeSrv } from '../../dashboard/services/TimeSrv';
import Mock = jest.Mock;
jest.mock('@grafana/runtime', () => ({
...((jest.requireActual('@grafana/runtime') as unknown) as object),
config: {
...((jest.requireActual('@grafana/runtime') as unknown) as any).config,
featureToggles: {
fullRangeLogsVolume: true,
},
},
}));
const t = toUtc();
const testRange = {
from: t,
@ -69,6 +81,22 @@ const defaultInitialState = {
},
};
function setupQueryResponse(state: StoreState) {
(state.explore[ExploreId.left].datasourceInstance?.query as Mock).mockReturnValueOnce(
of({
error: { message: 'test error' },
data: [
new MutableDataFrame({
fields: [{ name: 'test', values: new ArrayVector() }],
meta: {
preferredVisualisationType: 'graph',
},
}),
],
} as DataQueryResponse)
);
}
describe('runQueries', () => {
it('should pass dataFrames to state even if there is error in response', async () => {
setTimeSrv({
@ -77,19 +105,7 @@ describe('runQueries', () => {
const { dispatch, getState }: { dispatch: ThunkDispatch; getState: () => StoreState } = configureStore({
...(defaultInitialState as any),
});
(getState().explore[ExploreId.left].datasourceInstance?.query as Mock).mockReturnValueOnce(
of({
error: { message: 'test error' },
data: [
new MutableDataFrame({
fields: [{ name: 'test', values: new ArrayVector() }],
meta: {
preferredVisualisationType: 'graph',
},
}),
],
} as DataQueryResponse)
);
setupQueryResponse(getState());
await dispatch(runQueries(ExploreId.left));
expect(getState().explore[ExploreId.left].showMetrics).toBeTruthy();
expect(getState().explore[ExploreId.left].graphResult).toBeDefined();
@ -303,4 +319,136 @@ describe('reducer', () => {
expect(getState().explore[ExploreId.left].cache).toEqual([]);
});
});
describe('logs volume', () => {
let dispatch: ThunkDispatch,
getState: () => StoreState,
mockLogsVolumeDataProvider: () => Observable<DataQueryResponse>;
beforeEach(() => {
mockLogsVolumeDataProvider = () => {
return of(
{ state: LoadingState.Loading, error: undefined, data: [] },
{ state: LoadingState.Done, error: undefined, data: [{}] }
);
};
const store: { dispatch: ThunkDispatch; getState: () => StoreState } = configureStore({
...(defaultInitialState as any),
explore: {
[ExploreId.left]: {
...defaultInitialState.explore[ExploreId.left],
autoLoadLogsVolume: false,
datasourceInstance: {
query: jest.fn(),
meta: {
id: 'something',
},
getLogsVolumeDataProvider: () => {
return mockLogsVolumeDataProvider();
},
},
},
},
});
dispatch = store.dispatch;
getState = store.getState;
});
it('should not load logs volume automatically after running the query if auto-loading is disabled', async () => {
setupQueryResponse(getState());
getState().explore.autoLoadLogsVolume = false;
await dispatch(runQueries(ExploreId.left));
expect(getState().explore[ExploreId.left].logsVolumeData).not.toBeDefined();
});
it('should load logs volume automatically after running the query if auto-loading is enabled', async () => {
setupQueryResponse(getState());
getState().explore.autoLoadLogsVolume = true;
await dispatch(runQueries(ExploreId.left));
expect(getState().explore[ExploreId.left].logsVolumeData).toMatchObject({
state: LoadingState.Done,
error: undefined,
data: [{}],
});
});
it('when auto-load is enabled after running the query it should load logs volume data after changing auto-load option', async () => {
setupQueryResponse(getState());
await dispatch(runQueries(ExploreId.left));
expect(getState().explore[ExploreId.left].logsVolumeDataProvider).toBeDefined();
expect(getState().explore[ExploreId.left].logsVolumeData).not.toBeDefined();
await dispatch(changeAutoLogsVolume(ExploreId.left, true));
expect(getState().explore.autoLoadLogsVolume).toEqual(true);
expect(getState().explore[ExploreId.left].logsVolumeData).toMatchObject({
state: LoadingState.Done,
error: undefined,
data: [{}],
});
});
it('should allow loading logs volume on demand if auto-load is disabled', async () => {
setupQueryResponse(getState());
getState().explore.autoLoadLogsVolume = false;
await dispatch(runQueries(ExploreId.left));
expect(getState().explore[ExploreId.left].logsVolumeData).not.toBeDefined();
await dispatch(loadLogsVolumeData(ExploreId.left));
expect(getState().explore.autoLoadLogsVolume).toEqual(false);
expect(getState().explore[ExploreId.left].logsVolumeData).toMatchObject({
state: LoadingState.Done,
error: undefined,
data: [{}],
});
});
it('should cancel any unfinished logs volume queries', async () => {
setupQueryResponse(getState());
let unsubscribes: Function[] = [];
mockLogsVolumeDataProvider = () => {
return ({
subscribe: () => {
const unsubscribe = jest.fn();
unsubscribes.push(unsubscribe);
return {
unsubscribe,
};
},
} as unknown) as Observable<DataQueryResponse>;
};
await dispatch(runQueries(ExploreId.left));
// no subscriptions created yet
expect(unsubscribes).toHaveLength(0);
await dispatch(loadLogsVolumeData(ExploreId.left));
// loading in progress - one subscription created, not cleaned up yet
expect(unsubscribes).toHaveLength(1);
expect(unsubscribes[0]).not.toBeCalled();
setupQueryResponse(getState());
await dispatch(runQueries(ExploreId.left));
// new query was run - first subscription is cleaned up, no new subscriptions yet
expect(unsubscribes).toHaveLength(1);
expect(unsubscribes[0]).toBeCalled();
await dispatch(loadLogsVolumeData(ExploreId.left));
// new subscription is created, only the old was was cleaned up
expect(unsubscribes).toHaveLength(2);
expect(unsubscribes[0]).toBeCalled();
expect(unsubscribes[1]).not.toBeCalled();
});
});
});

@ -1,9 +1,11 @@
import { mergeMap, throttleTime } from 'rxjs/operators';
import { identity, Unsubscribable, of } from 'rxjs';
import { identity, Observable, of, SubscriptionLike, Unsubscribable } from 'rxjs';
import {
DataQuery,
DataQueryErrorType,
DataQueryResponse,
DataSourceApi,
hasLogsVolumeSupport,
LoadingState,
PanelData,
PanelEvents,
@ -30,11 +32,12 @@ import { notifyApp } from '../../../core/actions';
import { runRequest } from '../../query/state/runRequest';
import { decorateData } from '../utils/decorators';
import { createErrorNotification } from '../../../core/copy/appNotification';
import { richHistoryUpdatedAction, stateSave } from './main';
import { richHistoryUpdatedAction, stateSave, storeAutoLoadLogsVolumeAction } from './main';
import { AnyAction, createAction, PayloadAction } from '@reduxjs/toolkit';
import { updateTime } from './time';
import { historyUpdatedAction } from './history';
import { createEmptyQueryResponse, createCacheKey, getResultsFromCache } from './utils';
import { createCacheKey, createEmptyQueryResponse, getResultsFromCache } from './utils';
import { config } from '@grafana/runtime';
//
// Actions and Payloads
@ -98,10 +101,43 @@ export interface QueryStoreSubscriptionPayload {
exploreId: ExploreId;
querySubscription: Unsubscribable;
}
export const queryStoreSubscriptionAction = createAction<QueryStoreSubscriptionPayload>(
'explore/queryStoreSubscription'
);
export interface StoreLogsVolumeDataProvider {
exploreId: ExploreId;
logsVolumeDataProvider?: Observable<DataQueryResponse>;
}
/**
* Stores available logs volume provider after running the query. Used internally by runQueries().
*/
const storeLogsVolumeDataProviderAction = createAction<StoreLogsVolumeDataProvider>(
'explore/storeLogsVolumeDataProviderAction'
);
export interface StoreLogsVolumeDataSubscriptionPayload {
exploreId: ExploreId;
logsVolumeDataSubscription?: SubscriptionLike;
}
/**
* Stores current logs volume subscription for given explore pane.
*/
const storeLogsVolumeDataSubscriptionAction = createAction<StoreLogsVolumeDataSubscriptionPayload>(
'explore/storeLogsVolumeDataSubscriptionAction'
);
/**
* Stores data returned by the provider. Used internally by loadLogsVolumeData().
*/
const updateLogsVolumeDataAction = createAction<{
exploreId: ExploreId;
logsVolumeData: DataQueryResponse;
}>('explore/updateLogsVolumeDataAction');
export interface QueryEndedPayload {
exploreId: ExploreId;
response: ExplorePanelData;
@ -166,6 +202,7 @@ export interface ClearCachePayload {
exploreId: ExploreId;
}
export const clearCacheAction = createAction<ClearCachePayload>('explore/clearCache');
//
// Action creators
//
@ -281,7 +318,7 @@ export const runQueries = (
dispatch(clearCache(exploreId));
}
const richHistory = getState().explore.richHistory;
const { richHistory, autoLoadLogsVolume } = getState().explore;
const exploreItemState = getState().explore[exploreId]!;
const {
datasourceInstance,
@ -296,6 +333,7 @@ export const runQueries = (
refreshInterval,
absoluteRange,
cache,
logsVolumeDataProvider,
} = exploreItemState;
let newQuerySub;
@ -304,7 +342,11 @@ export const runQueries = (
// If we have results saved in cache, we are going to use those results instead of running queries
if (cachedValue) {
newQuerySub = of(cachedValue)
.pipe(mergeMap((data: PanelData) => decorateData(data, queryResponse, absoluteRange, refreshInterval, queries)))
.pipe(
mergeMap((data: PanelData) =>
decorateData(data, queryResponse, absoluteRange, refreshInterval, queries, !!logsVolumeDataProvider)
)
)
.subscribe((data) => {
if (!data.error) {
dispatch(stateSave());
@ -357,7 +399,16 @@ export const runQueries = (
// rendering. In case this is optimized this can be tweaked, but also it should be only as fast as user
// actually can see what is happening.
live ? throttleTime(500) : identity,
mergeMap((data: PanelData) => decorateData(data, queryResponse, absoluteRange, refreshInterval, queries))
mergeMap((data: PanelData) =>
decorateData(
data,
queryResponse,
absoluteRange,
refreshInterval,
queries,
!!getState().explore[exploreId]!.logsVolumeDataProvider
)
)
)
.subscribe(
(data) => {
@ -402,6 +453,26 @@ export const runQueries = (
console.error(error);
}
);
if (config.featureToggles.fullRangeLogsVolume && hasLogsVolumeSupport(datasourceInstance)) {
const logsVolumeDataProvider = datasourceInstance.getLogsVolumeDataProvider(transaction.request);
dispatch(
storeLogsVolumeDataProviderAction({
exploreId,
logsVolumeDataProvider,
})
);
if (autoLoadLogsVolume && logsVolumeDataProvider) {
dispatch(loadLogsVolumeData(exploreId));
}
} else {
dispatch(
storeLogsVolumeDataProviderAction({
exploreId,
logsVolumeDataProvider: undefined,
})
);
}
}
dispatch(queryStoreSubscriptionAction({ exploreId, querySubscription: newQuerySub }));
@ -458,6 +529,40 @@ export function clearCache(exploreId: ExploreId): ThunkResult<void> {
};
}
/**
* Uses storeLogsVolumeDataProviderAction to update the state and load logs volume when auto-load
* is enabled and logs volume hasn't been loaded yet.
*/
export function changeAutoLogsVolume(exploreId: ExploreId, autoLoadLogsVolume: boolean): ThunkResult<void> {
return (dispatch, getState) => {
dispatch(storeAutoLoadLogsVolumeAction(autoLoadLogsVolume));
const state = getState().explore[exploreId]!;
// load logs volume automatically after switching
const logsVolumeData = state.logsVolumeData;
if (!logsVolumeData?.data && autoLoadLogsVolume) {
dispatch(loadLogsVolumeData(exploreId));
}
};
}
/**
* Initializes loading logs volume data and stores emitted value.
*/
export function loadLogsVolumeData(exploreId: ExploreId): ThunkResult<void> {
return (dispatch, getState) => {
const { logsVolumeDataProvider } = getState().explore[exploreId]!;
if (logsVolumeDataProvider) {
const logsVolumeDataSubscription = logsVolumeDataProvider.subscribe({
next: (logsVolumeData: DataQueryResponse) => {
dispatch(updateLogsVolumeDataAction({ exploreId, logsVolumeData }));
},
});
dispatch(storeLogsVolumeDataSubscriptionAction({ exploreId, logsVolumeDataSubscription }));
}
};
}
//
// Reducer
//
@ -569,6 +674,37 @@ export const queryReducer = (state: ExploreItemState, action: AnyAction): Explor
};
}
if (storeLogsVolumeDataProviderAction.match(action)) {
let { logsVolumeDataProvider } = action.payload;
if (state.logsVolumeDataSubscription) {
state.logsVolumeDataSubscription.unsubscribe();
}
return {
...state,
logsVolumeDataProvider,
logsVolumeDataSubscription: undefined,
// clear previous data, with a new provider the previous data becomes stale
logsVolumeData: undefined,
};
}
if (storeLogsVolumeDataSubscriptionAction.match(action)) {
const { logsVolumeDataSubscription } = action.payload;
return {
...state,
logsVolumeDataSubscription,
};
}
if (updateLogsVolumeDataAction.match(action)) {
let { logsVolumeData } = action.payload;
return {
...state,
logsVolumeData,
};
}
if (queryStreamUpdatedAction.match(action)) {
return processQueryResponse(state, action);
}

@ -1,4 +1,5 @@
import {
AbsoluteTimeRange,
DataSourceApi,
EventBusExtended,
ExploreUrlState,
@ -6,7 +7,6 @@ import {
HistoryItem,
LoadingState,
PanelData,
AbsoluteTimeRange,
} from '@grafana/data';
import { ExploreItemState } from 'app/types/explore';
@ -50,6 +50,8 @@ export const makeExplorePaneState = (): ExploreItemState => ({
logsResult: null,
eventBridge: (null as unknown) as EventBusExtended,
cache: [],
logsVolumeDataProvider: undefined,
logsVolumeData: undefined,
});
export const createEmptyQueryResponse = (): PanelData => ({

@ -131,7 +131,12 @@ export const decorateWithTableResult = (data: ExplorePanelData): Observable<Expl
};
export const decorateWithLogsResult = (
options: { absoluteRange?: AbsoluteTimeRange; refreshInterval?: string; queries?: DataQuery[] } = {}
options: {
absoluteRange?: AbsoluteTimeRange;
refreshInterval?: string;
queries?: DataQuery[];
fullRangeLogsVolumeAvailable?: boolean;
} = {}
) => (data: ExplorePanelData): ExplorePanelData => {
if (data.logsFrames.length === 0) {
return { ...data, logsResult: null };
@ -142,7 +147,10 @@ export const decorateWithLogsResult = (
const sortOrder = refreshIntervalToSortOrder(options.refreshInterval);
const sortedNewResults = sortLogsResult(newResults, sortOrder);
const rows = sortedNewResults.rows;
const series = sortedNewResults.series;
const series =
config.featureToggles.fullRangeLogsVolume && options.fullRangeLogsVolumeAvailable
? undefined
: sortedNewResults.series;
const logsResult = { ...sortedNewResults, rows, series };
return { ...data, logsResult };
@ -154,13 +162,14 @@ export function decorateData(
queryResponse: PanelData,
absoluteRange: AbsoluteTimeRange,
refreshInterval: string | undefined,
queries: DataQuery[] | undefined
queries: DataQuery[] | undefined,
fullRangeLogsVolumeAvailable: boolean
): Observable<ExplorePanelData> {
return of(data).pipe(
map((data: PanelData) => preProcessPanelData(data, queryResponse)),
map(decorateWithFrameTypeMetadata),
map(decorateWithGraphResult),
map(decorateWithLogsResult({ absoluteRange, refreshInterval, queries })),
map(decorateWithLogsResult({ absoluteRange, refreshInterval, queries, fullRangeLogsVolumeAvailable })),
mergeMap(decorateWithTableResult)
);
}

@ -0,0 +1,107 @@
import { MockObservableDataSourceApi } from '../../../../../test/mocks/datasource_srv';
import { createLokiLogsVolumeProvider } from './logsVolumeProvider';
import LokiDatasource from '../datasource';
import { DataQueryRequest, DataQueryResponse, FieldType, LoadingState, toDataFrame } from '@grafana/data';
import { LokiQuery } from '../types';
import { Observable } from 'rxjs';
function createFrame(labels: object, timestamps: number[], values: number[]) {
return toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: timestamps },
{
name: 'Number',
type: FieldType.number,
values,
labels,
},
],
});
}
function createExpectedFields(levelName: string, timestamps: number[], values: number[]) {
return [
{ name: 'Time', values: { buffer: timestamps } },
{
name: 'Value',
config: { displayNameFromDS: levelName },
values: { buffer: values },
},
];
}
describe('LokiLogsVolumeProvider', () => {
let volumeProvider: Observable<DataQueryResponse>,
datasource: MockObservableDataSourceApi,
request: DataQueryRequest<LokiQuery>;
function setup(datasourceSetup: () => void) {
datasourceSetup();
request = ({
targets: [{ expr: '{app="app01"}' }, { expr: '{app="app02"}' }],
} as unknown) as DataQueryRequest<LokiQuery>;
volumeProvider = createLokiLogsVolumeProvider((datasource as unknown) as LokiDatasource, request);
}
function setupMultipleResults() {
// level=unknown
const resultAFrame1 = createFrame({ app: 'app01' }, [100, 200, 300], [5, 5, 5]);
// level=error
const resultAFrame2 = createFrame({ app: 'app01', level: 'error' }, [100, 200, 300], [0, 1, 0]);
// level=unknown
const resultBFrame1 = createFrame({ app: 'app02' }, [100, 200, 300], [1, 2, 3]);
// level=error
const resultBFrame2 = createFrame({ app: 'app02', level: 'error' }, [100, 200, 300], [1, 1, 1]);
datasource = new MockObservableDataSourceApi('loki', [
{
data: [resultAFrame1, resultAFrame2],
},
{
data: [resultBFrame1, resultBFrame2],
},
]);
}
function setupErrorResponse() {
datasource = new MockObservableDataSourceApi('loki', [], undefined, 'Error message');
}
it('aggregates data frames by level', async () => {
setup(setupMultipleResults);
await expect(volumeProvider).toEmitValuesWith((received) => {
expect(received).toMatchObject([
{ state: LoadingState.Loading, error: undefined, data: [] },
{
state: LoadingState.Done,
error: undefined,
data: [
{
fields: createExpectedFields('unknown', [100, 200, 300], [6, 7, 8]),
},
{
fields: createExpectedFields('error', [100, 200, 300], [1, 2, 1]),
},
],
},
]);
});
});
it('returns error', async () => {
setup(setupErrorResponse);
await expect(volumeProvider).toEmitValuesWith((received) => {
expect(received).toMatchObject([
{ state: LoadingState.Loading, error: undefined, data: [] },
{
state: LoadingState.Error,
error: 'Error message',
data: [],
},
'Error message',
]);
});
});
});

@ -0,0 +1,175 @@
import {
DataFrame,
DataQueryRequest,
DataQueryResponse,
FieldCache,
FieldColorModeId,
FieldConfig,
FieldType,
getLogLevelFromKey,
Labels,
LoadingState,
LogLevel,
MutableDataFrame,
toDataFrame,
} from '@grafana/data';
import { LokiQuery } from '../types';
import { Observable } from 'rxjs';
import { cloneDeep } from 'lodash';
import LokiDatasource, { isMetricsQuery } from '../datasource';
import { LogLevelColor } from '../../../../core/logs_model';
import { BarAlignment, GraphDrawStyle, StackingMode } from '@grafana/schema';
export function createLokiLogsVolumeProvider(
datasource: LokiDatasource,
dataQueryRequest: DataQueryRequest<LokiQuery>
): Observable<DataQueryResponse> {
const logsVolumeRequest = cloneDeep(dataQueryRequest);
logsVolumeRequest.targets = logsVolumeRequest.targets
.filter((target) => target.expr && !isMetricsQuery(target.expr))
.map((target) => {
return {
...target,
expr: `sum by (level) (count_over_time(${target.expr}[$__interval]))`,
};
});
return new Observable((observer) => {
let rawLogsVolume: DataFrame[] = [];
observer.next({
state: LoadingState.Loading,
error: undefined,
data: [],
});
const subscription = datasource.query(logsVolumeRequest).subscribe({
complete: () => {
const aggregatedLogsVolume = aggregateRawLogsVolume(rawLogsVolume);
observer.next({
state: LoadingState.Done,
error: undefined,
data: aggregatedLogsVolume,
});
observer.complete();
},
next: (dataQueryResponse: DataQueryResponse) => {
rawLogsVolume = rawLogsVolume.concat(dataQueryResponse.data.map(toDataFrame));
},
error: (error) => {
observer.next({
state: LoadingState.Error,
error: error,
data: [],
});
observer.error(error);
},
});
return () => {
subscription?.unsubscribe();
};
});
}
/**
* Add up values for the same level and create a single data frame for each level
*/
function aggregateRawLogsVolume(rawLogsVolume: DataFrame[]): DataFrame[] {
const logsVolumeByLevelMap: { [level in LogLevel]?: DataFrame[] } = {};
let levels = 0;
rawLogsVolume.forEach((dataFrame) => {
let valueField;
try {
valueField = new FieldCache(dataFrame).getFirstFieldOfType(FieldType.number);
} catch {}
// If value field doesn't exist skip the frame (it may happen with instant queries)
if (!valueField) {
return;
}
const level: LogLevel = valueField.labels ? getLogLevelFromLabels(valueField.labels) : LogLevel.unknown;
if (!logsVolumeByLevelMap[level]) {
logsVolumeByLevelMap[level] = [];
levels++;
}
logsVolumeByLevelMap[level]!.push(dataFrame);
});
return Object.keys(logsVolumeByLevelMap).map((level: string) => {
return aggregateFields(logsVolumeByLevelMap[level as LogLevel]!, getFieldConfig(level as LogLevel, levels));
});
}
function getFieldConfig(level: LogLevel, levels: number) {
const name = levels === 1 && level === LogLevel.unknown ? 'logs' : level;
const color = LogLevelColor[level];
return {
displayNameFromDS: name,
color: {
mode: FieldColorModeId.Fixed,
fixedColor: color,
},
custom: {
drawStyle: GraphDrawStyle.Bars,
barAlignment: BarAlignment.Center,
barWidthFactor: 0.9,
barMaxWidth: 5,
lineColor: color,
pointColor: color,
fillColor: color,
lineWidth: 1,
fillOpacity: 100,
stacking: {
mode: StackingMode.Normal,
group: 'A',
},
},
};
}
/**
* Create a new data frame with a single field and values creating by adding field values
* from all provided data frames
*/
function aggregateFields(dataFrames: DataFrame[], config: FieldConfig): DataFrame {
const aggregatedDataFrame = new MutableDataFrame();
if (!dataFrames.length) {
return aggregatedDataFrame;
}
const totalLength = dataFrames[0].length;
const timeField = new FieldCache(dataFrames[0]).getFirstFieldOfType(FieldType.time);
if (!timeField) {
return aggregatedDataFrame;
}
aggregatedDataFrame.addField({ name: 'Time', type: FieldType.time }, totalLength);
aggregatedDataFrame.addField({ name: 'Value', type: FieldType.number, config }, totalLength);
dataFrames.forEach((dataFrame) => {
dataFrame.fields.forEach((field) => {
if (field.type === FieldType.number) {
for (let pointIndex = 0; pointIndex < totalLength; pointIndex++) {
const currentValue = aggregatedDataFrame.get(pointIndex).Value;
const valueToAdd = field.values.get(pointIndex);
const totalValue =
currentValue === null && valueToAdd === null ? null : (currentValue || 0) + (valueToAdd || 0);
aggregatedDataFrame.set(pointIndex, { Value: totalValue, Time: timeField.values.get(pointIndex) });
}
}
});
});
return aggregatedDataFrame;
}
function getLogLevelFromLabels(labels: Labels): LogLevel {
const labelNames = ['level', 'lvl', 'loglevel'];
let levelLabel;
for (let labelName of labelNames) {
if (labelName in labels) {
levelLabel = labelName;
break;
}
}
return levelLabel ? getLogLevelFromKey(labels[levelLabel]) : LogLevel.unknown;
}

@ -920,6 +920,38 @@ describe('LokiDatasource', () => {
expect(contextQuery.expr).not.toContain('uniqueParsedLabel');
});
});
describe('logs volume data provider', () => {
it('creates provider for logs query', () => {
const ds = createLokiDSForTests();
const options = getQueryOptions<LokiQuery>({
targets: [{ expr: '{label=value}', refId: 'A' }],
});
expect(ds.getLogsVolumeDataProvider(options)).toBeDefined();
});
it('does not create provider for metrics query', () => {
const ds = createLokiDSForTests();
const options = getQueryOptions<LokiQuery>({
targets: [{ expr: 'rate({label=value}[1m])', refId: 'A' }],
});
expect(ds.getLogsVolumeDataProvider(options)).not.toBeDefined();
});
it('creates provider if at least one query is a logs query', () => {
const ds = createLokiDSForTests();
const options = getQueryOptions<LokiQuery>({
targets: [
{ expr: 'rate({label=value}[1m])', refId: 'A' },
{ expr: '{label=value}', refId: 'B' },
],
});
expect(ds.getLogsVolumeDataProvider(options)).toBeDefined();
});
});
});
function assertAdHocFilters(query: string, expectedResults: string, ds: LokiDatasource) {

@ -16,6 +16,7 @@ import {
DataQueryResponse,
DataSourceApi,
DataSourceInstanceSettings,
DataSourceWithLogsVolumeSupport,
dateMath,
DateTime,
FieldCache,
@ -52,6 +53,7 @@ import { serializeParams } from '../../../core/utils/fetch';
import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider';
import syntax from './syntax';
import { DEFAULT_RESOLUTION } from './components/LokiOptionFields';
import { createLokiLogsVolumeProvider } from './dataProviders/logsVolumeProvider';
export type RangeQueryOptions = DataQueryRequest<LokiQuery> | AnnotationQueryRequest<LokiQuery>;
export const DEFAULT_MAX_LINES = 1000;
@ -67,7 +69,9 @@ const DEFAULT_QUERY_PARAMS: Partial<LokiRangeQueryRequest> = {
query: '',
};
export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
export class LokiDatasource
extends DataSourceApi<LokiQuery, LokiOptions>
implements DataSourceWithLogsVolumeSupport<LokiQuery> {
private streams = new LiveStreams();
languageProvider: LanguageProvider;
maxLines: number;
@ -102,6 +106,11 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
return getBackendSrv().fetch<Record<string, any>>(req);
}
getLogsVolumeDataProvider(request: DataQueryRequest<LokiQuery>): Observable<DataQueryResponse> | undefined {
const isLogsVolumeAvailable = request.targets.some((target) => target.expr && !isMetricsQuery(target.expr));
return isLogsVolumeAvailable ? createLokiLogsVolumeProvider(this, request) : undefined;
}
query(options: DataQueryRequest<LokiQuery>): Observable<DataQueryResponse> {
const subQueries: Array<Observable<DataQueryResponse>> = [];
const scopedVars = {
@ -703,7 +712,7 @@ export function lokiSpecialRegexEscape(value: any) {
* Checks if the query expression uses function and so should return a time series instead of logs.
* Sometimes important to know that before we actually do the query.
*/
function isMetricsQuery(query: string): boolean {
export function isMetricsQuery(query: string): boolean {
const tokens = Prism.tokenize(query, syntax);
return tokens.some((t) => {
// Not sure in which cases it can be string maybe if nothing matched which means it should not be a function

@ -1,4 +1,4 @@
import { Unsubscribable } from 'rxjs';
import { Observable, SubscriptionLike, Unsubscribable } from 'rxjs';
import {
AbsoluteTimeRange,
DataFrame,
@ -12,6 +12,7 @@ import {
RawTimeRange,
TimeRange,
EventBusExtended,
DataQueryResponse,
} from '@grafana/data';
export enum ExploreId {
@ -44,6 +45,11 @@ export interface ExploreState {
* History of all queries
*/
richHistory: RichHistoryQuery[];
/**
* Auto-loading logs volume after running the query
*/
autoLoadLogsVolume: boolean;
}
export interface ExploreItemState {
@ -149,6 +155,12 @@ export interface ExploreItemState {
* We are currently caching last 5 query responses.
*/
cache: Array<{ key: string; value: PanelData }>;
// properties below should be more generic if we add more providers
// see also: DataSourceWithLogsVolumeSupport
logsVolumeDataProvider?: Observable<DataQueryResponse>;
logsVolumeDataSubscription?: SubscriptionLike;
logsVolumeData?: DataQueryResponse;
}
export interface ExploreUpdateState {

@ -5,6 +5,7 @@ import {
DataSourceInstanceSettings,
DataSourcePluginMeta,
} from '@grafana/data';
import { Observable } from 'rxjs';
export class DatasourceSrvMock {
constructor(private defaultDS: DataSourceApi, private datasources: { [name: string]: DataSourceApi }) {
@ -51,3 +52,34 @@ export class MockDataSourceApi extends DataSourceApi {
return Promise.resolve();
}
}
export class MockObservableDataSourceApi extends DataSourceApi {
results: DataQueryResponse[] = [{ data: [] }];
constructor(name?: string, results?: DataQueryResponse[], meta?: any, private error: string | null = null) {
super({ name: name ? name : 'MockDataSourceApi' } as DataSourceInstanceSettings);
if (results) {
this.results = results;
}
this.meta = meta || ({} as DataSourcePluginMeta);
}
query(request: DataQueryRequest): Observable<DataQueryResponse> {
return new Observable((observer) => {
if (this.error) {
observer.error(this.error);
}
if (this.results) {
this.results.forEach((response) => observer.next(response));
observer.complete();
}
});
}
testDatasource() {
return Promise.resolve();
}
}

Loading…
Cancel
Save