Loki: Visually distinguish error logs for LogQL2 (#28359)
* Loki: Add errored logs and update UI * Update messaging * Add icon and tooltip for errored logs * Update name of variable for more semantic meaning * Add tests * Update test * Refactor, remove unnecessary state * Update packages/grafana-data/src/types/logs.ts * Update packages/grafana-ui/src/components/Logs/LogDetails.tsx Co-authored-by: Giordano Ricci <gio.ricci@grafana.com> Co-authored-by: Giordano Ricci <gio.ricci@grafana.com>
This commit is contained in:
co-authored by
Giordano Ricci
parent
62f5641aa9
commit
3f39b4b601
@@ -27,10 +27,12 @@ export enum LogLevel {
|
|||||||
unknown = 'unknown',
|
unknown = 'unknown',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Used for meta information such as common labels or returned log rows in logs view in Explore
|
||||||
export enum LogsMetaKind {
|
export enum LogsMetaKind {
|
||||||
Number,
|
Number,
|
||||||
String,
|
String,
|
||||||
LabelsMap,
|
LabelsMap,
|
||||||
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum LogsSortOrder {
|
export enum LogsSortOrder {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
calculateStats,
|
calculateStats,
|
||||||
getLogLevelFromKey,
|
getLogLevelFromKey,
|
||||||
sortLogsResult,
|
sortLogsResult,
|
||||||
|
checkLogsError,
|
||||||
} from './logs';
|
} from './logs';
|
||||||
|
|
||||||
describe('getLoglevel()', () => {
|
describe('getLoglevel()', () => {
|
||||||
@@ -352,3 +353,15 @@ describe('sortLogsResult', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('checkLogsError()', () => {
|
||||||
|
const log = ({
|
||||||
|
labels: {
|
||||||
|
__error__: 'Error Message',
|
||||||
|
foo: 'boo',
|
||||||
|
},
|
||||||
|
} as any) as LogRowModel;
|
||||||
|
test('should return correct error if error is present', () => {
|
||||||
|
expect(checkLogsError(log)).toStrictEqual({ hasError: true, errorMessage: 'Error Message' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -210,3 +210,16 @@ export const sortLogsResult = (logsResult: LogsModel | null, sortOrder: LogsSort
|
|||||||
|
|
||||||
export const sortLogRows = (logRows: LogRowModel[], sortOrder: LogsSortOrder) =>
|
export const sortLogRows = (logRows: LogRowModel[], sortOrder: LogsSortOrder) =>
|
||||||
sortOrder === LogsSortOrder.Ascending ? logRows.sort(sortInAscendingOrder) : logRows.sort(sortInDescendingOrder);
|
sortOrder === LogsSortOrder.Ascending ? logRows.sort(sortInAscendingOrder) : logRows.sort(sortInDescendingOrder);
|
||||||
|
|
||||||
|
// Currently supports only error condition in Loki logs
|
||||||
|
export const checkLogsError = (logRow: LogRowModel): { hasError: boolean; errorMessage?: string } => {
|
||||||
|
if (logRow.labels.__error__) {
|
||||||
|
return {
|
||||||
|
hasError: true,
|
||||||
|
errorMessage: logRow.labels.__error__,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hasError: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ describe('LogDetails', () => {
|
|||||||
expect(wrapper.text().includes('key1label1key2label2')).toBe(true);
|
expect(wrapper.text().includes('key1label1key2label2')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('when log row has error', () => {
|
||||||
|
it('should not render log level border', () => {
|
||||||
|
const wrapper = setup({ hasError: true }, undefined);
|
||||||
|
expect(wrapper.find({ 'aria-label': 'Log level' }).html()).not.toContain('logs-row__level');
|
||||||
|
});
|
||||||
|
});
|
||||||
describe('when row entry has parsable fields', () => {
|
describe('when row entry has parsable fields', () => {
|
||||||
it('should render heading ', () => {
|
it('should render heading ', () => {
|
||||||
const wrapper = setup(undefined, { entry: 'test=successful' });
|
const wrapper = setup(undefined, { entry: 'test=successful' });
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface Props extends Themeable {
|
|||||||
showDuplicates: boolean;
|
showDuplicates: boolean;
|
||||||
getRows: () => LogRowModel[];
|
getRows: () => LogRowModel[];
|
||||||
className?: string;
|
className?: string;
|
||||||
|
hasError?: boolean;
|
||||||
onMouseEnter?: () => void;
|
onMouseEnter?: () => void;
|
||||||
onMouseLeave?: () => void;
|
onMouseLeave?: () => void;
|
||||||
onClickFilterLabel?: (key: string, value: string) => void;
|
onClickFilterLabel?: (key: string, value: string) => void;
|
||||||
@@ -70,6 +71,7 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
|||||||
const {
|
const {
|
||||||
row,
|
row,
|
||||||
theme,
|
theme,
|
||||||
|
hasError,
|
||||||
onClickFilterOutLabel,
|
onClickFilterOutLabel,
|
||||||
onClickFilterLabel,
|
onClickFilterLabel,
|
||||||
getRows,
|
getRows,
|
||||||
@@ -88,6 +90,8 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
|||||||
const labelsAvailable = Object.keys(labels).length > 0;
|
const labelsAvailable = Object.keys(labels).length > 0;
|
||||||
const fields = getAllFields(row, getFieldLinks);
|
const fields = getAllFields(row, getFieldLinks);
|
||||||
const parsedFieldsAvailable = fields && fields.length > 0;
|
const parsedFieldsAvailable = fields && fields.length > 0;
|
||||||
|
// If logs with error, we are not showing the level color
|
||||||
|
const levelClassName = cx(!hasError && [style.logsRowLevel, styles.logsRowLevelDetails]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@@ -96,7 +100,7 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
|||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={onMouseLeave}
|
||||||
>
|
>
|
||||||
{showDuplicates && <td />}
|
{showDuplicates && <td />}
|
||||||
<td className={cx(style.logsRowLevel, styles.logsRowLevelDetails)} />
|
<td className={levelClassName} aria-label="Log level" />
|
||||||
<td colSpan={4}>
|
<td colSpan={4}>
|
||||||
<div className={style.logDetailsContainer}>
|
<div className={style.logDetailsContainer}>
|
||||||
<table className={style.logDetailsTable}>
|
<table className={style.logDetailsTable}>
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
DataQueryResponse,
|
DataQueryResponse,
|
||||||
GrafanaTheme,
|
GrafanaTheme,
|
||||||
dateTimeFormat,
|
dateTimeFormat,
|
||||||
|
checkLogsError,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { Icon } from '../Icon/Icon';
|
import { Icon } from '../Icon/Icon';
|
||||||
|
import { Tooltip } from '../Tooltip/Tooltip';
|
||||||
import { cx, css } from 'emotion';
|
import { cx, css } from 'emotion';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -72,6 +74,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
|||||||
label: hoverBackground;
|
label: hoverBackground;
|
||||||
background-color: ${bgColor};
|
background-color: ${bgColor};
|
||||||
`,
|
`,
|
||||||
|
errorLogRow: css`
|
||||||
|
label: erroredLogRow;
|
||||||
|
color: ${theme.colors.textWeak};
|
||||||
|
`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
/**
|
/**
|
||||||
@@ -160,22 +166,27 @@ class UnThemedLogRow extends PureComponent<Props, State> {
|
|||||||
const { showDetails, showContext, hasHoverBackground } = this.state;
|
const { showDetails, showContext, hasHoverBackground } = this.state;
|
||||||
const style = getLogRowStyles(theme, row.logLevel);
|
const style = getLogRowStyles(theme, row.logLevel);
|
||||||
const styles = getStyles(theme);
|
const styles = getStyles(theme);
|
||||||
const hoverBackground = cx(style.logsRow, { [styles.hoverBackground]: hasHoverBackground });
|
const { errorMessage, hasError } = checkLogsError(row);
|
||||||
|
const logRowBackground = cx(style.logsRow, {
|
||||||
|
[styles.hoverBackground]: hasHoverBackground,
|
||||||
|
[styles.errorLogRow]: hasError,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr
|
<tr className={logRowBackground} onClick={this.toggleDetails}>
|
||||||
className={hoverBackground}
|
|
||||||
onMouseEnter={this.addHoverBackground}
|
|
||||||
onMouseLeave={this.clearHoverBackground}
|
|
||||||
onClick={this.toggleDetails}
|
|
||||||
>
|
|
||||||
{showDuplicates && (
|
{showDuplicates && (
|
||||||
<td className={style.logsRowDuplicates}>
|
<td className={style.logsRowDuplicates}>
|
||||||
{row.duplicates && row.duplicates > 0 ? `${row.duplicates + 1}x` : null}
|
{row.duplicates && row.duplicates > 0 ? `${row.duplicates + 1}x` : null}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className={style.logsRowLevel} />
|
<td className={cx({ [style.logsRowLevel]: !hasError })}>
|
||||||
|
{hasError && (
|
||||||
|
<Tooltip content={`Error: ${errorMessage}`} placement="right" theme="error">
|
||||||
|
<Icon className={style.logIconError} name="exclamation-triangle" size="sm" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
{!allowDetails && (
|
{!allowDetails && (
|
||||||
<td title={showDetails ? 'Hide log details' : 'See log details'} className={style.logsRowToggleDetails}>
|
<td title={showDetails ? 'Hide log details' : 'See log details'} className={style.logsRowToggleDetails}>
|
||||||
<Icon className={styles.topVerticalAlign} name={showDetails ? 'angle-down' : 'angle-right'} />
|
<Icon className={styles.topVerticalAlign} name={showDetails ? 'angle-down' : 'angle-right'} />
|
||||||
@@ -207,7 +218,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
|
|||||||
</tr>
|
</tr>
|
||||||
{this.state.showDetails && (
|
{this.state.showDetails && (
|
||||||
<LogDetails
|
<LogDetails
|
||||||
className={hoverBackground}
|
className={logRowBackground}
|
||||||
onMouseEnter={this.addHoverBackground}
|
onMouseEnter={this.addHoverBackground}
|
||||||
onMouseLeave={this.clearHoverBackground}
|
onMouseLeave={this.clearHoverBackground}
|
||||||
showDuplicates={showDuplicates}
|
showDuplicates={showDuplicates}
|
||||||
@@ -218,6 +229,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
|
|||||||
onClickHideParsedField={onClickHideParsedField}
|
onClickHideParsedField={onClickHideParsedField}
|
||||||
getRows={getRows}
|
getRows={getRows}
|
||||||
row={row}
|
row={row}
|
||||||
|
hasError={hasError}
|
||||||
showParsedFields={showParsedFields}
|
showParsedFields={showParsedFields}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const getLogRowStyles = stylesFactory((theme: GrafanaTheme, logLevel?: Lo
|
|||||||
let logColor = selectThemeVariant({ light: theme.palette.gray5, dark: theme.palette.gray2 }, theme.type);
|
let logColor = selectThemeVariant({ light: theme.palette.gray5, dark: theme.palette.gray2 }, theme.type);
|
||||||
const borderColor = selectThemeVariant({ light: theme.palette.gray5, dark: theme.palette.gray2 }, theme.type);
|
const borderColor = selectThemeVariant({ light: theme.palette.gray5, dark: theme.palette.gray2 }, theme.type);
|
||||||
const bgColor = selectThemeVariant({ light: theme.palette.gray5, dark: theme.palette.dark4 }, theme.type);
|
const bgColor = selectThemeVariant({ light: theme.palette.gray5, dark: theme.palette.dark4 }, theme.type);
|
||||||
|
const hoverBgColor = selectThemeVariant({ light: theme.palette.gray7, dark: theme.palette.dark2 }, theme.type);
|
||||||
const context = css`
|
const context = css`
|
||||||
label: context;
|
label: context;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
@@ -92,7 +93,7 @@ export const getLogRowStyles = stylesFactory((theme: GrafanaTheme, logLevel?: Lo
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: ${theme.colors.bodyBg};
|
background: ${hoverBgColor};
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
logsRowDuplicates: css`
|
logsRowDuplicates: css`
|
||||||
@@ -116,6 +117,10 @@ export const getLogRowStyles = stylesFactory((theme: GrafanaTheme, logLevel?: Lo
|
|||||||
background-color: ${logColor};
|
background-color: ${logColor};
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
logIconError: css`
|
||||||
|
color: ${theme.palette.red};
|
||||||
|
margin-left: -5px;
|
||||||
|
`,
|
||||||
logsRowToggleDetails: css`
|
logsRowToggleDetails: css`
|
||||||
label: logs-row-toggle-details__level;
|
label: logs-row-toggle-details__level;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -252,6 +252,81 @@ describe('dataFrameToLogsModel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('given one series with error should return expected logs model', () => {
|
||||||
|
const series: DataFrame[] = [
|
||||||
|
new MutableDataFrame({
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'time',
|
||||||
|
type: FieldType.time,
|
||||||
|
values: ['2019-04-26T09:28:11.352440161Z', '2019-04-26T14:42:50.991981292Z'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'message',
|
||||||
|
type: FieldType.string,
|
||||||
|
values: [
|
||||||
|
't=2019-04-26T11:05:28+0200 lvl=info msg="Initializing DatasourceCacheService" logger=server',
|
||||||
|
't=2019-04-26T16:42:50+0200 lvl=eror msg="new token…t unhashed token=56d9fdc5c8b7400bd51b060eea8ca9d7',
|
||||||
|
],
|
||||||
|
labels: {
|
||||||
|
filename: '/var/log/grafana/grafana.log',
|
||||||
|
job: 'grafana',
|
||||||
|
__error__: 'Failed while parsing',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: FieldType.string,
|
||||||
|
values: ['foo', 'bar'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
limit: 1000,
|
||||||
|
custom: {
|
||||||
|
error: 'Error when parsing some of the logs',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const logsModel = dataFrameToLogsModel(series, 1, 'utc');
|
||||||
|
expect(logsModel.hasUniqueLabels).toBeFalsy();
|
||||||
|
expect(logsModel.rows).toHaveLength(2);
|
||||||
|
expect(logsModel.rows).toMatchObject([
|
||||||
|
{
|
||||||
|
entry: 't=2019-04-26T11:05:28+0200 lvl=info msg="Initializing DatasourceCacheService" logger=server',
|
||||||
|
labels: { filename: '/var/log/grafana/grafana.log', job: 'grafana', __error__: 'Failed while parsing' },
|
||||||
|
logLevel: 'info',
|
||||||
|
uniqueLabels: {},
|
||||||
|
uid: 'foo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entry: 't=2019-04-26T16:42:50+0200 lvl=eror msg="new token…t unhashed token=56d9fdc5c8b7400bd51b060eea8ca9d7',
|
||||||
|
labels: { filename: '/var/log/grafana/grafana.log', job: 'grafana', __error__: 'Failed while parsing' },
|
||||||
|
logLevel: 'error',
|
||||||
|
uniqueLabels: {},
|
||||||
|
uid: 'bar',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(logsModel.series).toHaveLength(2);
|
||||||
|
expect(logsModel.meta).toHaveLength(3);
|
||||||
|
expect(logsModel.meta![0]).toMatchObject({
|
||||||
|
label: 'Common labels',
|
||||||
|
value: series[0].fields[1].labels,
|
||||||
|
kind: LogsMetaKind.LabelsMap,
|
||||||
|
});
|
||||||
|
expect(logsModel.meta![1]).toMatchObject({
|
||||||
|
label: 'Limit',
|
||||||
|
value: `1000 (2 returned)`,
|
||||||
|
kind: LogsMetaKind.String,
|
||||||
|
});
|
||||||
|
expect(logsModel.meta![2]).toMatchObject({
|
||||||
|
label: '',
|
||||||
|
value: 'Error when parsing some of the logs',
|
||||||
|
kind: LogsMetaKind.Error,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('given one series without labels should return expected logs model', () => {
|
it('given one series without labels should return expected logs model', () => {
|
||||||
const series: DataFrame[] = [
|
const series: DataFrame[] = [
|
||||||
new MutableDataFrame({
|
new MutableDataFrame({
|
||||||
|
|||||||
@@ -419,11 +419,22 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel | undefi
|
|||||||
// Hack to print loki stats in Explore. Should be using proper stats display via drawer in Explore (rework in 7.1)
|
// Hack to print loki stats in Explore. Should be using proper stats display via drawer in Explore (rework in 7.1)
|
||||||
let totalBytes = 0;
|
let totalBytes = 0;
|
||||||
const queriesVisited: { [refId: string]: boolean } = {};
|
const queriesVisited: { [refId: string]: boolean } = {};
|
||||||
|
// To add just 1 error message
|
||||||
|
let errorMetaAdded = false;
|
||||||
|
|
||||||
for (const series of logSeries) {
|
for (const series of logSeries) {
|
||||||
const totalBytesKey = series.meta?.custom?.lokiQueryStatKey;
|
const totalBytesKey = series.meta?.custom?.lokiQueryStatKey;
|
||||||
const { refId } = series; // Stats are per query, keeping track by refId
|
const { refId } = series; // Stats are per query, keeping track by refId
|
||||||
|
|
||||||
|
if (!errorMetaAdded && series.meta?.custom?.error) {
|
||||||
|
meta.push({
|
||||||
|
label: '',
|
||||||
|
value: series.meta?.custom.error,
|
||||||
|
kind: LogsMetaKind.Error,
|
||||||
|
});
|
||||||
|
errorMetaAdded = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (refId && !queriesVisited[refId]) {
|
if (refId && !queriesVisited[refId]) {
|
||||||
if (totalBytesKey && series.meta?.stats) {
|
if (totalBytesKey && series.meta?.stats) {
|
||||||
const byteStat = series.meta.stats.find(stat => stat.displayName === totalBytesKey);
|
const byteStat = series.meta.stats.find(stat => stat.displayName === totalBytesKey);
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ function renderMetaItem(value: any, kind: LogsMetaKind) {
|
|||||||
<LogLabels labels={value} />
|
<LogLabels labels={value} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
} else if (kind === LogsMetaKind.Error) {
|
||||||
|
return <span className="logs-meta-item__error">{value}</span>;
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
|||||||
margin-right: ${theme.spacing.d};
|
margin-right: ${theme.spacing.d};
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
|
||||||
|
.logs-meta-item__error {
|
||||||
|
color: ${theme.palette.red};
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
metaLabel: css`
|
metaLabel: css`
|
||||||
margin-right: calc(${theme.spacing.d} / 2);
|
margin-right: calc(${theme.spacing.d} / 2);
|
||||||
|
|||||||
@@ -325,6 +325,10 @@ export function lokiStreamsToDataframes(
|
|||||||
const dataFrame = lokiStreamResultToDataFrame(stream, reverse);
|
const dataFrame = lokiStreamResultToDataFrame(stream, reverse);
|
||||||
enhanceDataFrame(dataFrame, config);
|
enhanceDataFrame(dataFrame, config);
|
||||||
|
|
||||||
|
if (meta.custom && dataFrame.fields.some(f => f.labels && Object.keys(f.labels).some(l => l === '__error__'))) {
|
||||||
|
meta.custom.error = 'Error when parsing some of the logs';
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...dataFrame,
|
...dataFrame,
|
||||||
refId: target.refId,
|
refId: target.refId,
|
||||||
|
|||||||
Reference in New Issue
Block a user