Elasticsearch: Enable full range log volume histogram (#41202)

* Rename "Logs volume" labels to "Log volume"

Code references are kept intact as there's a lot of them, it could be renamed in a separate PR just with renaming

* Add log level docs

* Remove feature flag to enable log volume by default

* Update error message

* Update docs

* Fix unit test

* Fix unit test

Queries are now run automatically

* Add extra param for Loki API

* Remove "Load volume" button

* Update documentation about log volume

* Move comment

* Make reload button more accessible

* Update docs/sources/explore/logs-integration.md

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

* Hide full range log volume for Loki behind the feature toggle

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>
This commit is contained in:
Piotr Jamróz
2021-11-10 11:20:30 +01:00
committed by GitHub
parent c96c92d712
commit 7d2e9aa979
16 changed files with 120 additions and 126 deletions

View File

@@ -66,7 +66,7 @@ You can use the Loki query editor to create log and metric queries.
### Log browser
With Loki log browser you can easily navigate trough your list of labels and values and construct the query of your choice. Log browser has multi-step selection:
With Loki log browser you can easily navigate through your list of labels and values and construct the query of your choice. Log browser has multi-step selection:
1. Choose the labels you would like to consider for your search.
2. Pick the values for selected labels. Log browser supports facetting and therefore it shows you only possible label combinations.

View File

@@ -21,7 +21,11 @@ During an infrastructure monitoring and incident response, you can dig deeper in
### Logs visualization
Results of log queries are shown as histograms in the graph and individual logs are displayed below. If the data source does not send histogram data for the requested time range, the logs model computes a time series based on the log row counts bucketed by an automatically calculated time interval and the start of the histogram is then anchored by the first log row's timestamp from the result. The end of the time series is anchored to the time picker's **To** range.
Results of log queries are shown as histograms in the graph and individual logs are explained in the following sections.
If the data source supports a full range log volume histogram, the graph with log distribution for all entered log queries is shown automatically. This feature is currently supported by Elasticsearch data source.
If the data source does not support loading full range log volume histogram, the logs model computes a time series based on the log row counts bucketed by an automatically calculated time interval, and the first log row's timestamp then anchors the start of the histogram from the result. The end of the time series is anchored to the time picker's **To** range.
#### Log level

View File

@@ -52,7 +52,6 @@ export interface FeatureToggles {
recordedQueries: boolean;
newNavigation: boolean;
fullRangeLogsVolume: boolean;
autoLoadFullRangeLogsVolume: boolean;
}
/**

View File

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

View File

@@ -54,7 +54,8 @@ export const LogLevelColor = {
[LogLevel.unknown]: getThemeColor('#8e8e8e', '#dde4ed'),
};
const SECOND = 1000;
const MILLISECOND = 1;
const SECOND = 1000 * MILLISECOND;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
@@ -633,7 +634,8 @@ export function queryLogsVolume<T extends DataQuery>(
logsVolumeRequest: DataQueryRequest<T>,
options: LogsVolumeQueryOptions<T>
): Observable<DataQueryResponse> {
const intervalInfo = getIntervalInfo(logsVolumeRequest.scopedVars);
const timespan = options.range.to.valueOf() - options.range.from.valueOf();
const intervalInfo = getIntervalInfo(logsVolumeRequest.scopedVars, timespan);
logsVolumeRequest.interval = intervalInfo.interval;
logsVolumeRequest.scopedVars.__interval = { value: intervalInfo.interval, text: intervalInfo.interval };
if (intervalInfo.intervalMs !== undefined) {
@@ -692,11 +694,15 @@ export function queryLogsVolume<T extends DataQuery>(
});
}
function getIntervalInfo(scopedVars: ScopedVars): { interval: string; intervalMs?: number } {
function getIntervalInfo(scopedVars: ScopedVars, timespanMs: number): { interval: string; intervalMs?: number } {
if (scopedVars.__interval) {
let intervalMs: number = scopedVars.__interval_ms.value;
let interval = '';
if (intervalMs > HOUR) {
// below 5 seconds we force the resolution to be per 1ms as interval in scopedVars is not less than 10ms
if (timespanMs < SECOND * 5) {
intervalMs = MILLISECOND;
interval = '1ms';
} else if (intervalMs > HOUR) {
intervalMs = DAY;
interval = '1d';
} else if (intervalMs > MINUTE) {

View File

@@ -69,8 +69,6 @@ interface Props extends Themeable2 {
getFieldLinks: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
addResultsToCache: () => void;
clearCache: () => void;
loadingLogsVolumeAvailable: boolean;
onClickLoadLogsVolume: () => void;
}
interface State {
@@ -270,8 +268,6 @@ class UnthemedLogs extends PureComponent<Props, State> {
logsQueries,
clearCache,
addResultsToCache,
onClickLoadLogsVolume,
loadingLogsVolumeAvailable,
} = this.props;
const {
@@ -351,18 +347,6 @@ class UnthemedLogs extends PureComponent<Props, State> {
</InlineField>
</InlineFieldRow>
<div>
{loadingLogsVolumeAvailable && (
<Button
variant="secondary"
aria-label="Load volume button"
title="Execute a query to show full range logs volume"
onClick={onClickLoadLogsVolume}
icon="graph-bar"
className={styles.headerButton}
>
Load volume
</Button>
)}
<Button
variant="secondary"
disabled={isFlipping}

View File

@@ -6,7 +6,6 @@ import {
AbsoluteTimeRange,
Field,
hasLogsContextSupport,
hasLogsVolumeSupport,
LoadingState,
LogRowModel,
RawTimeRange,
@@ -14,7 +13,7 @@ import {
import { ExploreId, ExploreItemState } from 'app/types/explore';
import { StoreState } from 'app/types';
import { splitOpen } from './state/main';
import { addResultsToCache, clearCache, loadLogsVolumeData } from './state/query';
import { addResultsToCache, clearCache } from './state/query';
import { updateTimeRange } from './state/time';
import { getTimeZone } from '../profile/state/selectors';
import { LiveLogsWithTheme } from './LiveLogs';
@@ -22,7 +21,6 @@ import { Logs } from './Logs';
import { LogsCrossFadeTransition } from './utils/LogsCrossFadeTransition';
import { LiveTailControls } from './useLiveTailControls';
import { getFieldLinksForExplore } from './utils/links';
import { config } from 'app/core/config';
interface LogsContainerProps extends PropsFromRedux {
width: number;
@@ -69,7 +67,6 @@ class LogsContainer extends PureComponent<LogsContainerProps> {
render() {
const {
datasourceInstance,
loading,
loadingState,
logRows,
@@ -90,8 +87,6 @@ class LogsContainer extends PureComponent<LogsContainerProps> {
exploreId,
addResultsToCache,
clearCache,
logsVolumeDataProvider,
loadLogsVolumeData,
} = this.props;
if (!logRows) {
@@ -151,12 +146,6 @@ class LogsContainer extends PureComponent<LogsContainerProps> {
getFieldLinks={this.getFieldLinks}
addResultsToCache={() => addResultsToCache(exploreId)}
clearCache={() => clearCache(exploreId)}
loadingLogsVolumeAvailable={
hasLogsVolumeSupport(datasourceInstance) &&
!!logsVolumeDataProvider &&
!config.featureToggles.autoLoadFullRangeLogsVolume
}
onClickLoadLogsVolume={() => loadLogsVolumeData(exploreId)}
/>
</Collapse>
</LogsCrossFadeTransition>
@@ -207,7 +196,6 @@ const mapDispatchToProps = {
splitOpen,
addResultsToCache,
clearCache,
loadLogsVolumeData,
};
const connector = connect(mapStateToProps, mapDispatchToProps);

View File

@@ -27,7 +27,7 @@ function renderPanel(logsVolumeData?: DataQueryResponse) {
describe('LogsVolumePanel', () => {
it('shows loading message', () => {
renderPanel({ state: LoadingState.Loading, error: undefined, data: [] });
expect(screen.getByText('Logs volume is loading...')).toBeInTheDocument();
expect(screen.getByText('Log volume is loading...')).toBeInTheDocument();
});
it('shows no volume data', () => {
@@ -42,12 +42,12 @@ describe('LogsVolumePanel', () => {
it('shows error message', () => {
renderPanel({ state: LoadingState.Error, error: { data: { message: 'Test error message' } }, data: [] });
expect(screen.getByText('Failed to load volume logs for this query')).toBeInTheDocument();
expect(screen.getByText('Failed to load log volume for this query')).toBeInTheDocument();
expect(screen.getByText('Test error message')).toBeInTheDocument();
});
it('does not show the panel when there is no volume data', () => {
renderPanel(undefined);
expect(screen.queryByText('Logs volume')).not.toBeInTheDocument();
expect(screen.queryByText('Log volume')).not.toBeInTheDocument();
});
});

View File

@@ -1,5 +1,5 @@
import { AbsoluteTimeRange, DataQueryResponse, GrafanaTheme2, LoadingState, SplitOpen, TimeZone } from '@grafana/data';
import { Alert, Button, Collapse, TooltipDisplayMode, useStyles2, useTheme2 } from '@grafana/ui';
import { AbsoluteTimeRange, DataQueryResponse, LoadingState, SplitOpen, TimeZone } from '@grafana/data';
import { Alert, Button, Collapse, InlineField, TooltipDisplayMode, useStyles2, useTheme2 } from '@grafana/ui';
import { ExploreGraph } from './ExploreGraph';
import React from 'react';
import { css } from '@emotion/css';
@@ -27,12 +27,12 @@ export function LogsVolumePanel(props: Props) {
return null;
} else if (logsVolumeData?.error) {
return (
<Alert title="Failed to load volume logs for this query">
<Alert title="Failed to load log volume for this query">
{logsVolumeData.error.data?.message || logsVolumeData.error.statusText || logsVolumeData.error.message}
</Alert>
);
} else if (logsVolumeData?.state === LoadingState.Loading) {
LogsVolumePanelContent = <span>Logs volume is loading...</span>;
LogsVolumePanelContent = <span>Log volume is loading...</span>;
} else if (logsVolumeData?.data) {
if (logsVolumeData.data.length > 0) {
LogsVolumePanelContent = (
@@ -59,15 +59,14 @@ export function LogsVolumePanel(props: Props) {
if (zoomRatio !== undefined && zoomRatio < 1) {
zoomLevelInfo = (
<>
<span className={styles.zoomInfo}>Reload logs volume</span>
<Button size="xs" icon="sync" variant="secondary" onClick={onLoadLogsVolume} />
</>
<InlineField label="Reload log volume" transparent>
<Button size="xs" icon="sync" variant="secondary" onClick={onLoadLogsVolume} id="reload-volume" />
</InlineField>
);
}
return (
<Collapse label="Logs volume" isOpen={true} loading={logsVolumeData?.state === LoadingState.Loading}>
<Collapse label="Log volume" isOpen={true} loading={logsVolumeData?.state === LoadingState.Loading}>
<div style={{ height }} className={styles.contentContainer}>
{LogsVolumePanelContent}
</div>
@@ -76,7 +75,7 @@ export function LogsVolumePanel(props: Props) {
);
}
const getStyles = (theme: GrafanaTheme2) => {
const getStyles = () => {
return {
zoomInfoContainer: css`
display: flex;
@@ -85,10 +84,6 @@ const getStyles = (theme: GrafanaTheme2) => {
right: 5px;
top: 5px;
`,
zoomInfo: css`
padding: 8px;
font-size: ${theme.typography.bodySmall.fontSize};
`,
contentContainer: css`
display: flex;
align-items: center;

View File

@@ -3,19 +3,6 @@ import { noop } from 'lodash';
import { shallow } from 'enzyme';
import { SecondaryActions } from './SecondaryActions';
import { config } from '@grafana/runtime';
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,
autoLoadFullRangeLogsVolume: false,
},
},
}));
const addQueryRowButtonSelector = '[aria-label="Add row button"]';
const richHistoryButtonSelector = '[aria-label="Rich history button"]';
const queryInspectorButtonSelector = '[aria-label="Query inspector button"]';
@@ -79,8 +66,4 @@ describe('SecondaryActions', () => {
wrapper.find(queryInspectorButtonSelector).simulate('click');
expect(onClickQueryInspector).toBeCalled();
});
it('does not render load logs volume button when auto loading is enabled', () => {
config.featureToggles.autoLoadFullRangeLogsVolume = true;
});
});

View File

@@ -5,7 +5,6 @@ import {
cancelQueriesAction,
clearCache,
importQueries,
loadLogsVolumeData,
queryReducer,
runQueries,
scanStartAction,
@@ -33,18 +32,6 @@ import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { configureStore } from '../../../store/configureStore';
import { setTimeSrv } from '../../dashboard/services/TimeSrv';
import Mock = jest.Mock;
import { config } from '@grafana/runtime';
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,
autoLoadFullRangeLogsVolume: false,
},
},
}));
const t = toUtc();
const testRange = {
@@ -376,29 +363,21 @@ describe('reducer', () => {
it('should cancel any unfinished logs volume queries', async () => {
await dispatch(runQueries(ExploreId.left));
// no subscriptions created yet
expect(unsubscribes).toHaveLength(0);
await dispatch(loadLogsVolumeData(ExploreId.left));
// first query is run automatically
// 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);
// a new query is run while log volume query is not resolve yet...
expect(unsubscribes[0]).toBeCalled();
await dispatch(loadLogsVolumeData(ExploreId.left));
// new subscription is created, only the old was was cleaned up
// first subscription is cleaned up, a new subscription is created automatically
expect(unsubscribes).toHaveLength(2);
expect(unsubscribes[0]).toBeCalled();
expect(unsubscribes[1]).not.toBeCalled();
});
it('should load logs volume after running the query', async () => {
config.featureToggles.autoLoadFullRangeLogsVolume = true;
await dispatch(runQueries(ExploreId.left));
expect(unsubscribes).toHaveLength(1);
});

View File

@@ -38,7 +38,6 @@ import { AnyAction, createAction, PayloadAction } from '@reduxjs/toolkit';
import { updateTime } from './time';
import { historyUpdatedAction } from './history';
import { createCacheKey, createEmptyQueryResponse, getResultsFromCache } from './utils';
import { config } from '@grafana/runtime';
import deepEqual from 'fast-deep-equal';
//
@@ -478,7 +477,7 @@ export const runQueries = (
})
);
dispatch(cleanLogsVolumeAction({ exploreId }));
} else if (config.featureToggles.fullRangeLogsVolume && hasLogsVolumeSupport(datasourceInstance)) {
} else if (hasLogsVolumeSupport(datasourceInstance)) {
const logsVolumeDataProvider = datasourceInstance.getLogsVolumeDataProvider(transaction.request);
dispatch(
storeLogsVolumeDataProviderAction({
@@ -489,9 +488,7 @@ export const runQueries = (
const { logsVolumeData, absoluteRange } = getState().explore[exploreId]!;
if (!canReuseLogsVolumeData(logsVolumeData, queries, absoluteRange)) {
dispatch(cleanLogsVolumeAction({ exploreId }));
if (config.featureToggles.autoLoadFullRangeLogsVolume) {
dispatch(loadLogsVolumeData(exploreId));
}
dispatch(loadLogsVolumeData(exploreId));
}
} else {
dispatch(

View File

@@ -147,10 +147,7 @@ export const decorateWithLogsResult = (
const sortOrder = refreshIntervalToSortOrder(options.refreshInterval);
const sortedNewResults = sortLogsResult(newResults, sortOrder);
const rows = sortedNewResults.rows;
const series =
config.featureToggles.fullRangeLogsVolume && options.fullRangeLogsVolumeAvailable
? undefined
: sortedNewResults.series;
const series = options.fullRangeLogsVolumeAvailable ? undefined : sortedNewResults.series;
const logsResult = { ...sortedNewResults, rows, series };
return { ...data, logsResult };

View File

@@ -12,9 +12,9 @@ import {
MutableDataFrame,
FieldType,
} from '@grafana/data';
import { BackendSrvRequest, FetchResponse } from '@grafana/runtime';
import { BackendSrvRequest, FetchResponse, config } from '@grafana/runtime';
import LokiDatasource from './datasource';
import LokiDatasource, { RangeQueryOptions } from './datasource';
import { LokiQuery, LokiResponse, LokiResultType } from './types';
import { getQueryOptions } from 'test/helpers/getQueryOptions';
import { TemplateSrv } from 'app/features/templating/template_srv';
@@ -29,6 +29,12 @@ jest.mock('@grafana/runtime', () => ({
// @ts-ignore
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => backendSrv,
config: {
...((jest.requireActual('@grafana/runtime') as unknown) as any).config,
featureToggles: {
fullRangeLogsVolume: true,
},
},
}));
const rawRange = {
@@ -164,6 +170,30 @@ describe('LokiDatasource', () => {
// Step is in seconds (1 ms === 0.001 s)
expect(req.step).toEqual(0.001);
});
describe('log volume hint', () => {
let options: RangeQueryOptions;
beforeEach(() => {
const raw = { from: 'now', to: 'now-1h' };
const range = { from: dateTime(), to: dateTime(), raw: raw };
options = ({
range,
} as unknown) as RangeQueryOptions;
});
it('should add volume hint param for log volume queries', () => {
const target = { expr: '{job="grafana"}', refId: 'B', volumeQuery: true };
const req = ds.createRangeQuery(target, options as any, 1000);
expect(req.hint).toBe('logvolhist');
});
it('should not add volume hint param for regular queries', () => {
const target = { expr: '{job="grafana"}', refId: 'B', volumeQuery: false };
const req = ds.createRangeQuery(target, options as any, 1000);
expect(req.hint).not.toBeDefined();
});
});
});
describe('when doing logs queries with limits', () => {
@@ -922,34 +952,55 @@ describe('LokiDatasource', () => {
});
describe('logs volume data provider', () => {
it('creates provider for logs query', () => {
const ds = createLokiDSForTests();
const options = getQueryOptions<LokiQuery>({
targets: [{ expr: '{label=value}', refId: 'A' }],
describe('when feature toggle is enabled', () => {
beforeEach(() => {
config.featureToggles.fullRangeLogsVolume = true;
});
expect(ds.getLogsVolumeDataProvider(options)).toBeDefined();
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();
});
});
it('does not create provider for metrics query', () => {
const ds = createLokiDSForTests();
const options = getQueryOptions<LokiQuery>({
targets: [{ expr: 'rate({label=value}[1m])', refId: 'A' }],
describe('when feature toggle is disabled', () => {
beforeEach(() => {
config.featureToggles.fullRangeLogsVolume = false;
});
expect(ds.getLogsVolumeDataProvider(options)).not.toBeDefined();
});
it('does not create a provider for logs query', () => {
const ds = createLokiDSForTests();
const options = getQueryOptions<LokiQuery>({
targets: [{ expr: '{label=value}', refId: 'A' }],
});
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)).not.toBeDefined();
});
expect(ds.getLogsVolumeDataProvider(options)).toBeDefined();
});
});
});

View File

@@ -59,6 +59,7 @@ import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContext
import syntax from './syntax';
import { DEFAULT_RESOLUTION } from './components/LokiOptionFields';
import { queryLogsVolume } from 'app/core/logs_model';
import config from 'app/core/config';
export type RangeQueryOptions = DataQueryRequest<LokiQuery> | AnnotationQueryRequest<LokiQuery>;
export const DEFAULT_MAX_LINES = 1000;
@@ -118,6 +119,10 @@ export class LokiDatasource
}
getLogsVolumeDataProvider(request: DataQueryRequest<LokiQuery>): Observable<DataQueryResponse> | undefined {
if (!config.featureToggles.fullRangeLogsVolume) {
return undefined;
}
const isLogsVolumeAvailable = request.targets.some((target) => target.expr && !isMetricsQuery(target.expr));
if (!isLogsVolumeAvailable) {
return undefined;
@@ -130,6 +135,7 @@ export class LokiDatasource
return {
...target,
instant: false,
volumeQuery: true,
expr: `sum by (level) (count_over_time(${target.expr}[$__interval]))`,
};
});
@@ -242,9 +248,12 @@ export class LokiDatasource
};
}
const hint: { hint?: 'logvolhist' } = target.volumeQuery ? { hint: 'logvolhist' } : {};
return {
...DEFAULT_QUERY_PARAMS,
...range,
...hint,
query,
limit,
};

View File

@@ -14,6 +14,8 @@ export interface LokiRangeQueryRequest {
end?: number;
step?: number;
direction?: 'BACKWARD' | 'FORWARD';
// extra info passed to Loki API when a log volume query is run to distinguish it from any other query
hint?: 'logvolhist';
}
export enum LokiResultType {
@@ -33,6 +35,7 @@ export interface LokiQuery extends DataQuery {
resolution?: number;
range?: boolean;
instant?: boolean;
volumeQuery?: boolean;
}
export interface LokiOptions extends DataSourceJsonData {