mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Logs Volume Timeouts: Add a custom message with options (retry, close) for request timeouts (#65434)
* Logs Volume: identify timeouts and provide remediation action * Supplementary result error: refactor updated component API * Create helper to identify timeout errors * Update timeout identifying function * Add unit test * Update panel unit test * Update public/app/features/explore/utils/logsVolumeResponse.ts Co-authored-by: Giordano Ricci <me@giordanoricci.com> * Use some instead of reduce * Change alert type to info * Add comment * Remove unnecessary optional chaining * Remove unnecessary condition * Remove unnecessary wrapping arrow function --------- Co-authored-by: Giordano Ricci <me@giordanoricci.com>
This commit is contained in:
parent
e39c3b76dd
commit
fb83414b6a
@ -392,6 +392,7 @@ class UnthemedLogs extends PureComponent<Props, State> {
|
|||||||
onLoadLogsVolume={loadLogsVolumeData}
|
onLoadLogsVolume={loadLogsVolumeData}
|
||||||
onHiddenSeriesChanged={this.onToggleLogLevel}
|
onHiddenSeriesChanged={this.onToggleLogLevel}
|
||||||
eventBus={this.logsVolumeEventBus}
|
eventBus={this.logsVolumeEventBus}
|
||||||
|
onClose={() => this.onToggleLogsVolumeCollapse(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { DataQueryResponse, LoadingState, EventBusSrv } from '@grafana/data';
|
import { DataQueryResponse, LoadingState, EventBusSrv } from '@grafana/data';
|
||||||
@ -12,7 +13,7 @@ jest.mock('./Graph/ExploreGraph', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderPanel(logsVolumeData?: DataQueryResponse) {
|
function renderPanel(logsVolumeData?: DataQueryResponse, onLoadLogsVolume = () => {}) {
|
||||||
render(
|
render(
|
||||||
<LogsVolumePanelList
|
<LogsVolumePanelList
|
||||||
absoluteRange={{ from: 0, to: 1 }}
|
absoluteRange={{ from: 0, to: 1 }}
|
||||||
@ -21,7 +22,7 @@ function renderPanel(logsVolumeData?: DataQueryResponse) {
|
|||||||
width={100}
|
width={100}
|
||||||
onUpdateTimeRange={() => {}}
|
onUpdateTimeRange={() => {}}
|
||||||
logsVolumeData={logsVolumeData}
|
logsVolumeData={logsVolumeData}
|
||||||
onLoadLogsVolume={() => {}}
|
onLoadLogsVolume={onLoadLogsVolume}
|
||||||
onHiddenSeriesChanged={() => null}
|
onHiddenSeriesChanged={() => null}
|
||||||
eventBus={new EventBusSrv()}
|
eventBus={new EventBusSrv()}
|
||||||
/>
|
/>
|
||||||
@ -40,7 +41,7 @@ describe('LogsVolumePanelList', () => {
|
|||||||
expect(screen.getByText('Test error message')).toBeInTheDocument();
|
expect(screen.getByText('Test error message')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows long warning message', () => {
|
it('shows long warning message', async () => {
|
||||||
// we make a long message
|
// we make a long message
|
||||||
const messagePart = 'One two three four five six seven eight nine ten.';
|
const messagePart = 'One two three four five six seven eight nine ten.';
|
||||||
const message = messagePart + ' ' + messagePart + ' ' + messagePart;
|
const message = messagePart + ' ' + messagePart + ' ' + messagePart;
|
||||||
@ -48,7 +49,22 @@ describe('LogsVolumePanelList', () => {
|
|||||||
renderPanel({ state: LoadingState.Error, error: { data: { message } }, data: [] });
|
renderPanel({ state: LoadingState.Error, error: { data: { message } }, data: [] });
|
||||||
expect(screen.getByText('Failed to load log volume for this query')).toBeInTheDocument();
|
expect(screen.getByText('Failed to load log volume for this query')).toBeInTheDocument();
|
||||||
expect(screen.queryByText(message)).not.toBeInTheDocument();
|
expect(screen.queryByText(message)).not.toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show details' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Show details' }));
|
||||||
expect(screen.getByText(message)).toBeInTheDocument();
|
expect(screen.getByText(message)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a custom message for timeout errors', async () => {
|
||||||
|
const onLoadCallback = jest.fn();
|
||||||
|
renderPanel(
|
||||||
|
{
|
||||||
|
state: LoadingState.Error,
|
||||||
|
error: { data: { message: '{"status":"error","errorType":"timeout","error":"context deadline exceeded"}' } },
|
||||||
|
data: [],
|
||||||
|
},
|
||||||
|
onLoadCallback
|
||||||
|
);
|
||||||
|
expect(screen.getByText('The logs volume query is taking too long and has timed out')).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||||
|
expect(onLoadCallback).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -19,6 +19,7 @@ import { mergeLogsVolumeDataFrames } from '../logs/utils';
|
|||||||
|
|
||||||
import { LogsVolumePanel } from './LogsVolumePanel';
|
import { LogsVolumePanel } from './LogsVolumePanel';
|
||||||
import { SupplementaryResultError } from './SupplementaryResultError';
|
import { SupplementaryResultError } from './SupplementaryResultError';
|
||||||
|
import { isTimeoutErrorResponse } from './utils/logsVolumeResponse';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
logsVolumeData: DataQueryResponse | undefined;
|
logsVolumeData: DataQueryResponse | undefined;
|
||||||
@ -30,6 +31,7 @@ type Props = {
|
|||||||
onLoadLogsVolume: () => void;
|
onLoadLogsVolume: () => void;
|
||||||
onHiddenSeriesChanged: (hiddenSeries: string[]) => void;
|
onHiddenSeriesChanged: (hiddenSeries: string[]) => void;
|
||||||
eventBus: EventBus;
|
eventBus: EventBus;
|
||||||
|
onClose?(): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LogsVolumePanelList = ({
|
export const LogsVolumePanelList = ({
|
||||||
@ -42,6 +44,7 @@ export const LogsVolumePanelList = ({
|
|||||||
eventBus,
|
eventBus,
|
||||||
splitOpen,
|
splitOpen,
|
||||||
timeZone,
|
timeZone,
|
||||||
|
onClose,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const logVolumes: Record<string, DataFrame[]> = useMemo(() => {
|
const logVolumes: Record<string, DataFrame[]> = useMemo(() => {
|
||||||
const grouped = groupBy(logsVolumeData?.data || [], 'meta.custom.datasourceName');
|
const grouped = groupBy(logsVolumeData?.data || [], 'meta.custom.datasourceName');
|
||||||
@ -59,10 +62,22 @@ export const LogsVolumePanelList = ({
|
|||||||
return !isLogsVolumeLimited(data) && zoomRatio && zoomRatio < 1;
|
return !isLogsVolumeLimited(data) && zoomRatio && zoomRatio < 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const timeoutError = isTimeoutErrorResponse(logsVolumeData);
|
||||||
|
|
||||||
if (logsVolumeData?.state === LoadingState.Loading) {
|
if (logsVolumeData?.state === LoadingState.Loading) {
|
||||||
return <span>Loading...</span>;
|
return <span>Loading...</span>;
|
||||||
}
|
} else if (timeoutError) {
|
||||||
if (logsVolumeData?.error !== undefined) {
|
return (
|
||||||
|
<SupplementaryResultError
|
||||||
|
title="The logs volume query is taking too long and has timed out"
|
||||||
|
// Using info to avoid users thinking that the actual query has failed.
|
||||||
|
severity="info"
|
||||||
|
suggestedAction="Retry"
|
||||||
|
onSuggestedAction={onLoadLogsVolume}
|
||||||
|
onRemove={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (logsVolumeData?.error !== undefined) {
|
||||||
return <SupplementaryResultError error={logsVolumeData.error} title="Failed to load log volume for this query" />;
|
return <SupplementaryResultError error={logsVolumeData.error} title="Failed to load log volume for this query" />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
@ -1,23 +1,27 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { DataQueryError } from '@grafana/data';
|
import { DataQueryError } from '@grafana/data';
|
||||||
import { Alert, Button } from '@grafana/ui';
|
import { Alert, AlertVariant, Button } from '@grafana/ui';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
error: DataQueryError;
|
error?: DataQueryError;
|
||||||
title: string;
|
title: string;
|
||||||
|
severity?: AlertVariant;
|
||||||
|
suggestedAction?: string;
|
||||||
|
onSuggestedAction?(): void;
|
||||||
|
onRemove?(): void;
|
||||||
};
|
};
|
||||||
export function SupplementaryResultError(props: Props) {
|
export function SupplementaryResultError(props: Props) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const SHORT_ERROR_MESSAGE_LIMIT = 100;
|
const SHORT_ERROR_MESSAGE_LIMIT = 100;
|
||||||
const { error, title } = props;
|
const { error, title, suggestedAction, onSuggestedAction, onRemove, severity = 'warning' } = props;
|
||||||
// generic get-error-message-logic, taken from
|
// generic get-error-message-logic, taken from
|
||||||
// /public/app/features/explore/ErrorContainer.tsx
|
// /public/app/features/explore/ErrorContainer.tsx
|
||||||
const message = error.message || error.data?.message || '';
|
const message = error?.message || error?.data?.message || '';
|
||||||
const showButton = !isOpen && message.length > SHORT_ERROR_MESSAGE_LIMIT;
|
const showButton = !isOpen && message.length > SHORT_ERROR_MESSAGE_LIMIT;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert title={title} severity="warning">
|
<Alert title={title} severity={severity} onRemove={onRemove}>
|
||||||
{showButton ? (
|
{showButton ? (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@ -31,6 +35,11 @@ export function SupplementaryResultError(props: Props) {
|
|||||||
) : (
|
) : (
|
||||||
message
|
message
|
||||||
)}
|
)}
|
||||||
|
{suggestedAction && onSuggestedAction && (
|
||||||
|
<Button variant="primary" size="xs" onClick={onSuggestedAction}>
|
||||||
|
{suggestedAction}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
66
public/app/features/explore/utils/logsVolumeResponse.test.ts
Normal file
66
public/app/features/explore/utils/logsVolumeResponse.test.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { DataQueryResponse } from '@grafana/data';
|
||||||
|
|
||||||
|
import { isTimeoutErrorResponse } from './logsVolumeResponse';
|
||||||
|
|
||||||
|
const errorA =
|
||||||
|
'Get "http://localhost:3100/loki/api/v1/query_range?direction=backward&end=1680001200000000000&limit=1000&query=sum+by+%28level%29+%28count_over_time%28%7Bcontainer_name%3D%22docker-compose-app-1%22%7D%5B1h%5D%29%29&start=1679914800000000000&step=3600000ms": net/http: request canceled (Client.Timeout exceeded while awaiting headers)';
|
||||||
|
const errorB = '{"status":"error","errorType":"timeout","error":"context deadline exceeded"}';
|
||||||
|
|
||||||
|
describe('isTimeoutErrorResponse', () => {
|
||||||
|
test.each([errorA, errorB])(
|
||||||
|
'identifies timeout errors in the error.message attribute when the message is `%s`',
|
||||||
|
(timeoutError: string) => {
|
||||||
|
const response: DataQueryResponse = {
|
||||||
|
data: [],
|
||||||
|
error: {
|
||||||
|
message: timeoutError,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(isTimeoutErrorResponse(response)).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
test.each([errorA, errorB])(
|
||||||
|
'identifies timeout errors in the errors.message attribute when the message is `%s`',
|
||||||
|
(timeoutError: string) => {
|
||||||
|
const response: DataQueryResponse = {
|
||||||
|
data: [],
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: 'Something else',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: timeoutError,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(isTimeoutErrorResponse(response)).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
test.each([errorA, errorB])(
|
||||||
|
'identifies timeout errors in the errors.data.message attribute when the message is `%s`',
|
||||||
|
(timeoutError: string) => {
|
||||||
|
const response: DataQueryResponse = {
|
||||||
|
data: [],
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
message: 'Something else',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
message: timeoutError,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(isTimeoutErrorResponse(response)).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
test('does not report false positives', () => {
|
||||||
|
const response: DataQueryResponse = {
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
expect(isTimeoutErrorResponse(response)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
18
public/app/features/explore/utils/logsVolumeResponse.ts
Normal file
18
public/app/features/explore/utils/logsVolumeResponse.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { DataQueryError, DataQueryResponse } from '@grafana/data';
|
||||||
|
|
||||||
|
// Currently we can only infer if an error response is a timeout or not.
|
||||||
|
export function isTimeoutErrorResponse(response: DataQueryResponse | undefined): boolean {
|
||||||
|
if (!response) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!response.error && !response.errors) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = response.error ? [response.error] : response.errors || [];
|
||||||
|
|
||||||
|
return errors.some((error: DataQueryError) => {
|
||||||
|
const message = `${error.message || error.data?.message}`?.toLowerCase();
|
||||||
|
return message.includes('timeout');
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user