OTel logs: Move OTel augmented attributes out of the log line to a field (#110901)
* OTel: make attributes a field * Logs: normalize field names * chore: remove test * Translations * Cleaner approach compatible with "show original line" * Revert "Cleaner approach compatible with "show original line"" This reverts commit e27c3de4ede5538f12cc8fc27d0aee06906c7792. * formats: remove scope name from default attributes * Logs: sync panel state once * otel formatting: exclude dashboards * Fix dashboard exclusion * LogsMetaRow: introduce new reset option for OTel logs * Translations * Update test * Rename constant * processing: only add otel attributes field for otel logs * Implement defaultDisplayedFields * Update translations * Logs: initialize displayed fields with panel state * Address lint issues * LogLine: fix log attributes field title * Optimization: memo HighlightedLogRenderer * Otel log attributes: highlight when rendering * getOtelAttributesField: update * OTEL_RESOURCE_ATTRS_REGEX: exclude cluster, namespace, and pod * DisplayedFields: respect syntaxHighlighting state * chore: revert experimental changes * chore: use argument * chore: remove comment * formats: update tests * LogList: add integration test * LogLine: more integration tests * LogList: more integration tests * LogList: even more tests * LogList: add assertion * processing: update tests * formats: more tests * LogLabels: update test * LogLine: update test * Prettier * Logs Panel: add dashboard option * Translations * Table: exclude generated field * LogListContext: invert order of effects * Explore: remove unnecessary effect * Explore: unify displayed fields effects * Remove log * Rename field * Update supressions
This commit is contained in:
@@ -4542,11 +4542,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/plugins/panel/logs/types.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/plugins/panel/nodeGraph/Edge.tsx": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 1
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface Options {
|
||||
showCommonLabels: boolean;
|
||||
showControls?: boolean;
|
||||
showLabels: boolean;
|
||||
showLogAttributes?: boolean;
|
||||
showLogContextToggle: boolean;
|
||||
showTime: boolean;
|
||||
sortOrder: common.LogsSortOrder;
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
serializeStateToUrlParam,
|
||||
urlUtil,
|
||||
LogLevel,
|
||||
shallowCompare,
|
||||
} from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
@@ -53,7 +54,7 @@ import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll';
|
||||
import { LogRows } from 'app/features/logs/components/LogRows';
|
||||
import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal';
|
||||
import { LogLineContext } from 'app/features/logs/components/panel/LogLineContext';
|
||||
import { LogList, LogListControlOptions } from 'app/features/logs/components/panel/LogList';
|
||||
import { LogList, LogListOptions } from 'app/features/logs/components/panel/LogList';
|
||||
import { isDedupStrategy, isLogsSortOrder } from 'app/features/logs/components/panel/LogListContext';
|
||||
import { LogLevelColor, dedupLogRows } from 'app/features/logs/logsModel';
|
||||
import { getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils';
|
||||
@@ -205,7 +206,8 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending
|
||||
);
|
||||
const [isFlipping, setIsFlipping] = useState<boolean>(false);
|
||||
const [displayedFields, setDisplayedFields] = useState<string[]>([]);
|
||||
const [displayedFields, setDisplayedFields] = useState<string[]>(panelState?.logs?.displayedFields ?? []);
|
||||
const [defaultDisplayedFields, setDefaultDisplayedFields] = useState<string[]>([]);
|
||||
const [contextOpen, setContextOpen] = useState<boolean>(false);
|
||||
const [contextRow, setContextRow] = useState<LogRowModel | undefined>(undefined);
|
||||
const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState<PopoverContent>(PINNED_LOGS_MESSAGE);
|
||||
@@ -280,16 +282,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
store.set(visualisationTypeKey, visualisationType);
|
||||
}, [panelState?.logs?.visualisationType]);
|
||||
|
||||
useEffect(() => {
|
||||
let displayedFields: string[] = [];
|
||||
if (Array.isArray(panelState?.logs?.displayedFields)) {
|
||||
displayedFields = panelState?.logs?.displayedFields;
|
||||
} else if (panelState?.logs?.displayedFields && typeof panelState?.logs?.displayedFields === 'object') {
|
||||
displayedFields = Object.values(panelState?.logs?.displayedFields);
|
||||
}
|
||||
setDisplayedFields(displayedFields);
|
||||
}, [panelState?.logs?.displayedFields]);
|
||||
|
||||
useUnmount(() => {
|
||||
if (flipOrderTimer) {
|
||||
window.clearTimeout(flipOrderTimer.current);
|
||||
@@ -346,6 +338,15 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shallowCompare(displayedFields, panelState?.logs?.displayedFields ?? [])) {
|
||||
updatePanelState({
|
||||
...panelState?.logs,
|
||||
displayedFields,
|
||||
});
|
||||
}
|
||||
}, [displayedFields, panelState?.logs, updatePanelState]);
|
||||
|
||||
// actions
|
||||
const onLogRowHover = useCallback(
|
||||
(row?: LogRowModel) => {
|
||||
@@ -544,13 +545,9 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
if (index === -1) {
|
||||
const updatedDisplayedFields = displayedFields.concat(key);
|
||||
setDisplayedFields(updatedDisplayedFields);
|
||||
updatePanelState({
|
||||
...panelState?.logs,
|
||||
displayedFields: updatedDisplayedFields,
|
||||
});
|
||||
}
|
||||
},
|
||||
[displayedFields, panelState?.logs, updatePanelState]
|
||||
[displayedFields]
|
||||
);
|
||||
|
||||
const hideField = useCallback(
|
||||
@@ -559,22 +556,14 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
if (index > -1) {
|
||||
const updatedDisplayedFields = displayedFields.filter((k) => key !== k);
|
||||
setDisplayedFields(updatedDisplayedFields);
|
||||
updatePanelState({
|
||||
...panelState?.logs,
|
||||
displayedFields: updatedDisplayedFields,
|
||||
});
|
||||
}
|
||||
},
|
||||
[displayedFields, panelState?.logs, updatePanelState]
|
||||
[displayedFields]
|
||||
);
|
||||
|
||||
const clearDetectedFields = useCallback(() => {
|
||||
updatePanelState({
|
||||
...panelState?.logs,
|
||||
displayedFields: [],
|
||||
});
|
||||
const clearDisplayedFields = useCallback(() => {
|
||||
setDisplayedFields([]);
|
||||
}, [panelState?.logs, updatePanelState]);
|
||||
}, []);
|
||||
|
||||
const onCloseCallbackRef = useRef<() => void>(() => {});
|
||||
|
||||
@@ -703,7 +692,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
|
||||
const visibilityChangedRef = useRef(true);
|
||||
const onLogOptionsChange = useCallback(
|
||||
(option: LogListControlOptions, value: string | string[] | boolean) => {
|
||||
(option: LogListOptions, value: string | string[] | boolean) => {
|
||||
if (option === 'sortOrder' && isLogsSortOrder(value)) {
|
||||
sortOrderChanged(value);
|
||||
} else if (option === 'dedupStrategy' && isDedupStrategy(value)) {
|
||||
@@ -757,6 +746,8 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
|
||||
return newLevels;
|
||||
});
|
||||
} else if (option === 'defaultDisplayedFields' && Array.isArray(value)) {
|
||||
setDefaultDisplayedFields(value);
|
||||
}
|
||||
},
|
||||
[logsVolumeData?.data, logsVolumeEnabled, sortOrderChanged]
|
||||
@@ -985,7 +976,8 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
dedupStrategy={dedupStrategy}
|
||||
dedupCount={dedupCount}
|
||||
displayedFields={displayedFields}
|
||||
clearDetectedFields={clearDetectedFields}
|
||||
clearDisplayedFields={clearDisplayedFields}
|
||||
defaultDisplayedFields={defaultDisplayedFields}
|
||||
/>
|
||||
</div>
|
||||
<div className={cx(styles.logsSection, visualisationType === 'table' ? styles.logsTable : undefined)}>
|
||||
|
||||
@@ -26,7 +26,8 @@ const defaultProps: LogsMetaRowProps = {
|
||||
dedupCount: 0,
|
||||
displayedFields: [],
|
||||
logRows: [],
|
||||
clearDetectedFields: jest.fn(),
|
||||
clearDisplayedFields: jest.fn(),
|
||||
defaultDisplayedFields: [],
|
||||
};
|
||||
|
||||
const setup = (propOverrides?: object, disableDownload = false) => {
|
||||
@@ -61,7 +62,7 @@ describe('LogsMetaRow', () => {
|
||||
|
||||
it('renders a button to clear displayedfields', () => {
|
||||
const clearSpy = jest.fn();
|
||||
setup({ displayedFields: ['testField1234'], clearDetectedFields: clearSpy });
|
||||
setup({ displayedFields: ['testField1234'], clearDisplayedFields: clearSpy });
|
||||
fireEvent(
|
||||
screen.getByRole('button', {
|
||||
name: 'Show original line',
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, Labels, store } from '@grafana/data';
|
||||
import {
|
||||
LogsDedupStrategy,
|
||||
LogsMetaItem,
|
||||
LogsMetaKind,
|
||||
LogRowModel,
|
||||
CoreApp,
|
||||
Labels,
|
||||
store,
|
||||
shallowCompare,
|
||||
} from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { Button, Dropdown, Menu, ToolbarButton, useStyles2 } from '@grafana/ui';
|
||||
@@ -30,11 +39,20 @@ export type Props = {
|
||||
dedupCount: number;
|
||||
displayedFields: string[];
|
||||
logRows: LogRowModel[];
|
||||
clearDetectedFields: () => void;
|
||||
clearDisplayedFields: () => void;
|
||||
defaultDisplayedFields: string[];
|
||||
};
|
||||
|
||||
export const LogsMetaRow = memo(
|
||||
({ meta, dedupStrategy, dedupCount, displayedFields, clearDetectedFields, logRows }: Props) => {
|
||||
({
|
||||
meta,
|
||||
dedupStrategy,
|
||||
dedupCount,
|
||||
displayedFields,
|
||||
clearDisplayedFields,
|
||||
logRows,
|
||||
defaultDisplayedFields,
|
||||
}: Props) => {
|
||||
const style = useStyles2(getStyles);
|
||||
|
||||
const logsMetaItem: Array<LogsMetaItem | MetaItemProps> = [...meta];
|
||||
@@ -49,7 +67,7 @@ export const LogsMetaRow = memo(
|
||||
}
|
||||
|
||||
// Add detected fields info
|
||||
if (displayedFields?.length > 0) {
|
||||
if (displayedFields?.length > 0 && shallowCompare(displayedFields, defaultDisplayedFields) === false) {
|
||||
logsMetaItem.push(
|
||||
{
|
||||
label: t('explore.logs-meta-row.label.showing-only-selected-fields', 'Showing only selected fields'),
|
||||
@@ -58,8 +76,8 @@ export const LogsMetaRow = memo(
|
||||
{
|
||||
label: '',
|
||||
value: (
|
||||
<Button variant="primary" fill="outline" size="sm" onClick={clearDetectedFields}>
|
||||
<Trans i18nKey="explore.logs-meta-row.show-original-line">Show original line</Trans>
|
||||
<Button variant="primary" fill="outline" size="sm" onClick={clearDisplayedFields}>
|
||||
{t('explore.logs-meta-row.show-original-line', 'Show original line')}
|
||||
</Button>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { t } from '@grafana/i18n';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from 'app/features/logs/components/otel/formats';
|
||||
|
||||
import { getLogsFieldsStyles } from './LogsTableActiveFields';
|
||||
import { LogsTableEmptyFields } from './LogsTableEmptyFields';
|
||||
@@ -36,7 +37,9 @@ export const LogsTableAvailableFields = (props: {
|
||||
const theme = useTheme2();
|
||||
|
||||
const styles = getLogsFieldsStyles(theme);
|
||||
const labelKeys = Object.keys(labels).filter((labelName) => valueFilter(labelName));
|
||||
const labelKeys = Object.keys(labels)
|
||||
.filter((labelName) => labelName !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME)
|
||||
.filter((labelName) => valueFilter(labelName));
|
||||
if (labelKeys.length) {
|
||||
// Otherwise show list with a hardcoded order
|
||||
return (
|
||||
|
||||
@@ -20,7 +20,7 @@ import { LogsVisualisationType } from '../../explore/Logs/Logs';
|
||||
import { ControlledLogsTable } from './ControlledLogsTable';
|
||||
import { InfiniteScroll } from './InfiniteScroll';
|
||||
import { LogRows, Props } from './LogRows';
|
||||
import { LogListControlOptions } from './panel/LogList';
|
||||
import { LogListOptions } from './panel/LogList';
|
||||
import { LogListContextProvider, useLogListContext } from './panel/LogListContext';
|
||||
import { LogListControls } from './panel/LogListControls';
|
||||
import { ScrollToLogsEvent } from './panel/virtualization';
|
||||
@@ -30,7 +30,7 @@ export interface ControlledLogRowsProps extends Omit<Props, 'scrollElement'> {
|
||||
logsMeta?: LogsMetaItem[];
|
||||
loadMoreLogs?: (range: AbsoluteTimeRange) => void;
|
||||
logOptionsStorageKey?: string;
|
||||
onLogOptionsChange?: (option: LogListControlOptions, value: string | boolean | string[]) => void;
|
||||
onLogOptionsChange?: (option: LogListOptions, value: string | boolean | string[]) => void;
|
||||
range: TimeRange;
|
||||
filterLevels?: LogLevel[];
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
|
||||
import { LogLabels, LogLabelsList } from './LogLabels';
|
||||
import { getNormalizedFieldName } from './panel/processing';
|
||||
|
||||
describe('<LogLabels />', () => {
|
||||
it('renders notice when no labels are found', () => {
|
||||
@@ -96,6 +97,6 @@ describe('<LogLabelsList />', () => {
|
||||
render(<LogLabelsList labels={['bar', '42', LOG_LINE_BODY_FIELD_NAME]} />);
|
||||
expect(screen.queryByText('bar')).toBeInTheDocument();
|
||||
expect(screen.queryByText('42')).toBeInTheDocument();
|
||||
expect(screen.queryByText('log line')).toBeInTheDocument();
|
||||
expect(screen.queryByText(getNormalizedFieldName(LOG_LINE_BODY_FIELD_NAME))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { GrafanaTheme2, Labels } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Button, Icon, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
|
||||
import { getNormalizedFieldName } from './panel/processing';
|
||||
|
||||
// Levels are already encoded in color, filename is a Loki-ism
|
||||
const HIDDEN_LABELS = ['detected_level', 'level', 'lvl', 'filename'];
|
||||
@@ -111,7 +111,7 @@ export const LogLabelsList = memo(({ labels }: LogLabelsArrayProps) => {
|
||||
<span className={styles.logsLabels}>
|
||||
{labels.map((label) => (
|
||||
<LogLabel key={label} styles={styles} tooltip={label}>
|
||||
{label === LOG_LINE_BODY_FIELD_NAME ? t('logs.log-labels-list.log-line', 'log line') : label}
|
||||
{getNormalizedFieldName(label)}
|
||||
</LogLabel>
|
||||
))}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { createLogLine } from '../mocks/logRow';
|
||||
|
||||
import { getDisplayedFieldsForLogs, getOtelFormattedBody, OTEL_PROBE_FIELD } from './formats';
|
||||
import {
|
||||
getDisplayedFieldsForLogs,
|
||||
getOtelAttributesField,
|
||||
OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME,
|
||||
OTEL_PROBE_FIELD,
|
||||
} from './formats';
|
||||
|
||||
describe('getDisplayedFieldsForLogs', () => {
|
||||
test('Does not return displayed fields if not an OTel log line', () => {
|
||||
@@ -18,43 +23,97 @@ describe('getDisplayedFieldsForLogs', () => {
|
||||
|
||||
test('Returns displayed fields if the OTel probe field is present', () => {
|
||||
const log = createLogLine({
|
||||
labels: { [OTEL_PROBE_FIELD]: '1', telemetry_sdk_language: 'php', scope_name: 'scope' },
|
||||
labels: { [OTEL_PROBE_FIELD]: '1', telemetry_sdk_language: 'php', thread_name: 'John' },
|
||||
entry: `place="luna" 1ms 3 KB`,
|
||||
});
|
||||
|
||||
expect(getDisplayedFieldsForLogs([log])).toEqual(['scope_name', LOG_LINE_BODY_FIELD_NAME]);
|
||||
expect(getDisplayedFieldsForLogs([log])).toEqual([
|
||||
'thread_name',
|
||||
LOG_LINE_BODY_FIELD_NAME,
|
||||
OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME,
|
||||
]);
|
||||
expect(log.otelLanguage).toBe('php');
|
||||
});
|
||||
|
||||
test('Returns displayed fields if the OTel probe field is present and the language unknown', () => {
|
||||
const log = createLogLine({
|
||||
labels: { [OTEL_PROBE_FIELD]: '1', scope_name: 'scope' },
|
||||
labels: { [OTEL_PROBE_FIELD]: '1', exception_type: 'fatal', exception_message: 'message' },
|
||||
entry: `place="luna" 1ms 3 KB`,
|
||||
});
|
||||
|
||||
expect(getDisplayedFieldsForLogs([log])).toEqual(['scope_name', LOG_LINE_BODY_FIELD_NAME]);
|
||||
expect(getDisplayedFieldsForLogs([log])).toEqual([
|
||||
'exception_type',
|
||||
'exception_message',
|
||||
LOG_LINE_BODY_FIELD_NAME,
|
||||
OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME,
|
||||
]);
|
||||
expect(log.otelLanguage).toBe('unknown');
|
||||
});
|
||||
|
||||
test('Returns the minimal displayed fields if others are not present', () => {
|
||||
const log = createLogLine({
|
||||
labels: { [OTEL_PROBE_FIELD]: '1' },
|
||||
entry: `place="luna" 1ms 3 KB`,
|
||||
});
|
||||
|
||||
expect(getDisplayedFieldsForLogs([log])).toEqual([LOG_LINE_BODY_FIELD_NAME, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOtelFormattedBody', () => {
|
||||
test('Does not modify non OTel logs', () => {
|
||||
const log = createLogLine({ labels: { place: 'luna' }, entry: `place="luna" 1ms 3 KB` });
|
||||
expect(getOtelFormattedBody(log)).toEqual(`place="luna" 1ms 3 KB`);
|
||||
});
|
||||
|
||||
test('Returns an OTel augmented log line body', () => {
|
||||
describe('getOtelAttributesField', () => {
|
||||
test('Builds the OTel attributes fields from the log line fields including and excluding fields', () => {
|
||||
const log = createLogLine({
|
||||
labels: {
|
||||
severity_number: '1',
|
||||
telemetry_sdk_language: 'php',
|
||||
scope_name: 'scope',
|
||||
aws_ignore: 'ignored',
|
||||
key: 'value',
|
||||
otel: 'otel',
|
||||
aws_something: 'nope',
|
||||
k8s_something: 'nope',
|
||||
cluster: 'nope',
|
||||
namespace: 'nope',
|
||||
pod: 'nope',
|
||||
vcs_ref_head_name: 'main',
|
||||
field: 'value',
|
||||
},
|
||||
entry: `place="luna" 1ms 3 KB`,
|
||||
});
|
||||
expect(getOtelFormattedBody(log)).toEqual(`place="luna" 1ms 3 KB key=value otel=otel`);
|
||||
|
||||
expect(getOtelAttributesField(log, true)).toEqual('vcs_ref_head_name=main field=value');
|
||||
});
|
||||
|
||||
test('Correctly matches excluded labels', () => {
|
||||
const log = createLogLine({
|
||||
labels: {
|
||||
aws_something: 'nope',
|
||||
k8s_something: 'nope',
|
||||
cluster: 'nope',
|
||||
namespace: 'nope',
|
||||
pod: 'nope',
|
||||
cluster_1: 'yes',
|
||||
namespace_2: 'yes',
|
||||
pod_3: 'yes',
|
||||
vcs_ref_head_name: 'main',
|
||||
field: 'value',
|
||||
},
|
||||
entry: `place="luna" 1ms 3 KB`,
|
||||
});
|
||||
|
||||
expect(getOtelAttributesField(log, true)).toEqual(
|
||||
'cluster_1=yes namespace_2=yes pod_3=yes vcs_ref_head_name=main field=value'
|
||||
);
|
||||
});
|
||||
|
||||
test('Removes new lines when wrapping is disabled', () => {
|
||||
const log = createLogLine({
|
||||
labels: {
|
||||
aws_something: 'nope',
|
||||
k8s_something: 'nope',
|
||||
cluster: 'nope',
|
||||
namespace: 'nope',
|
||||
pod: 'nope',
|
||||
vcs_ref_head_name: 'ma\nin',
|
||||
field: 'val\nue',
|
||||
},
|
||||
entry: `place="luna" 1ms 3 KB`,
|
||||
});
|
||||
|
||||
expect(getOtelAttributesField(log, false)).toEqual('vcs_ref_head_name=main field=value');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { LogRowModel } from '@grafana/data';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { LogListModel } from '../panel/processing';
|
||||
import { LogListModel, NEWLINES_REGEX } from '../panel/processing';
|
||||
|
||||
/**
|
||||
* The presence of this field along log fields determines OTel origin.
|
||||
*/
|
||||
export const OTEL_PROBE_FIELD = 'severity_number';
|
||||
const OTEL_LANGUAGE_UNKNOWN = 'unknown';
|
||||
export function identifyOTelLanguages(logs: LogListModel[] | LogRowModel[]): string[] {
|
||||
|
||||
function identifyOTelLanguages(logs: LogListModel[] | LogRowModel[]): string[] {
|
||||
const languagesSet = new Set<string>();
|
||||
logs.forEach((log) => {
|
||||
const lang = identifyOTelLanguage(log);
|
||||
@@ -28,7 +29,7 @@ export function identifyOTelLanguage(log: LogListModel | LogRowModel): string |
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function getDisplayedFieldsForLanguages(logs: LogListModel[] | LogRowModel[], languages: string[]) {
|
||||
function getDisplayedFieldsForLanguages(logs: LogListModel[] | LogRowModel[], languages: string[]) {
|
||||
const displayedFields: string[] = [];
|
||||
|
||||
languages.forEach((language) => {
|
||||
@@ -41,7 +42,10 @@ export function getDisplayedFieldsForLanguages(logs: LogListModel[] | LogRowMode
|
||||
});
|
||||
|
||||
return displayedFields.filter(
|
||||
(field) => field === LOG_LINE_BODY_FIELD_NAME || logs.some((log) => log.labels[field] !== undefined)
|
||||
(field) =>
|
||||
field === LOG_LINE_BODY_FIELD_NAME ||
|
||||
field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME ||
|
||||
logs.some((log) => log.labels[field] !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,24 +59,34 @@ export function getDisplayFormatForLanguage(language: string) {
|
||||
}
|
||||
|
||||
export function getDefaultOTelDisplayFormat() {
|
||||
return ['scope_name', 'thread_name', 'exception_type', 'exception_message', LOG_LINE_BODY_FIELD_NAME];
|
||||
return [
|
||||
'thread_name',
|
||||
'exception_type',
|
||||
'exception_message',
|
||||
LOG_LINE_BODY_FIELD_NAME,
|
||||
OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME,
|
||||
];
|
||||
}
|
||||
|
||||
const OTEL_RESOURCE_ATTRS_REGEX =
|
||||
/^(aws_|cloud_|cloudfoundry_|container_|deployment_|faas_|gcp_|host_|k8s_|os_|process_|service_|telemetry_)/;
|
||||
/^(aws_|cloud_|cloudfoundry_|container_|deployment_|faas_|gcp_|host_|k8s_|os_|process_|service_|telemetry_|cluster$|namespace$|pod$)/;
|
||||
const OTEL_LOG_FIELDS_REGEX =
|
||||
/^(flags|observed_timestamp|scope_name|severity_number|severity_text|span_id|trace_id|detected_level)$/;
|
||||
/^(flags|observed_timestamp|severity_number|severity_text|span_id|trace_id|detected_level)$/;
|
||||
|
||||
export function getOtelFormattedBody(log: LogListModel) {
|
||||
if (!log.otelLanguage) {
|
||||
return log.raw;
|
||||
}
|
||||
export const OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME = '___OTEL_LOG_ATTRIBUTES___';
|
||||
|
||||
export function getOtelAttributesField(log: LogListModel, wrapLogMessage: boolean) {
|
||||
const additionalFields = Object.keys(log.labels).filter(
|
||||
(label) => !OTEL_RESOURCE_ATTRS_REGEX.test(label) && !OTEL_LOG_FIELDS_REGEX.test(label)
|
||||
);
|
||||
return (
|
||||
log.raw +
|
||||
' ' +
|
||||
additionalFields.map((field) => (log.labels[field] ? `${field}=${log.labels[field]}` : '')).join(' ')
|
||||
(label) =>
|
||||
!OTEL_RESOURCE_ATTRS_REGEX.test(label) &&
|
||||
!OTEL_LOG_FIELDS_REGEX.test(label) &&
|
||||
label !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME
|
||||
);
|
||||
const attributes = additionalFields
|
||||
.map((field) => (log.labels[field] ? `${field}=${log.labels[field]}` : ''))
|
||||
.join(' ');
|
||||
if (!wrapLogMessage) {
|
||||
return attributes.replace(NEWLINES_REGEX, '');
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('HighlightedLogRenderer', () => {
|
||||
}
|
||||
);
|
||||
|
||||
const { container } = render(<HighlightedLogRenderer log={log} />);
|
||||
const { container } = render(<HighlightedLogRenderer tokens={log.highlightedBodyTokens} />);
|
||||
|
||||
expect(container.innerHTML).toEqual(log.highlightedBody);
|
||||
});
|
||||
@@ -177,7 +177,7 @@ describe('HighlightedLogRenderer', () => {
|
||||
}
|
||||
);
|
||||
|
||||
const { container } = render(<HighlightedLogRenderer log={log} />);
|
||||
const { container } = render(<HighlightedLogRenderer tokens={log.highlightedBodyTokens} />);
|
||||
|
||||
expect(container.innerHTML).toEqual(log.highlightedBody);
|
||||
});
|
||||
@@ -201,7 +201,7 @@ describe('HighlightedLogRenderer', () => {
|
||||
}
|
||||
);
|
||||
|
||||
const { container } = render(<HighlightedLogRenderer log={log} />);
|
||||
const { container } = render(<HighlightedLogRenderer tokens={log.highlightedBodyTokens} />);
|
||||
|
||||
expect(container.innerHTML).toEqual(log.highlightedBody);
|
||||
});
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Token } from 'prismjs';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { LogListModel } from './processing';
|
||||
|
||||
export const HighlightedLogRenderer = ({ log }: { log: LogListModel }) => {
|
||||
export const HighlightedLogRenderer = memo(({ tokens }: { tokens: Array<string | Token> }) => {
|
||||
return (
|
||||
<>
|
||||
{log.highlightedBodyTokens.map((token, i) => (
|
||||
{tokens.map((token, i) => (
|
||||
<LogToken token={token} key={i} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
HighlightedLogRenderer.displayName = 'HighlightedLogRenderer';
|
||||
|
||||
const LogToken = ({ token }: { token: Token | string }) => {
|
||||
const LogToken = memo(({ token }: { token: Token | string }) => {
|
||||
if (typeof token === 'string') {
|
||||
return token;
|
||||
}
|
||||
@@ -30,4 +30,5 @@ const LogToken = ({ token }: { token: Token | string }) => {
|
||||
{typeof token.content === 'string' ? token.content : <LogToken token={token.content} />}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
});
|
||||
LogToken.displayName = 'LogToken';
|
||||
|
||||
@@ -2,9 +2,11 @@ import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { CoreApp, createTheme, getDefaultTimeRange, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { createLogLine } from '../mocks/logRow';
|
||||
import { getDisplayedFieldsForLogs, OTEL_PROBE_FIELD } from '../otel/formats';
|
||||
|
||||
import { getGridTemplateColumns, getStyles, LogLine, Props } from './LogLine';
|
||||
import { LogListFontSize } from './LogList';
|
||||
@@ -270,6 +272,55 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => {
|
||||
expect(screen.getByTestId('ansiLogLine')).toBeInTheDocument();
|
||||
expect(screen.queryByText(log.entry)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Highlights the OTel attributes field when rendered', () => {
|
||||
const originalState = config.featureToggles.otelLogsFormatting;
|
||||
config.featureToggles.otelLogsFormatting = true;
|
||||
log = createLogLine({
|
||||
labels: { [OTEL_PROBE_FIELD]: '1', service: 'some service' },
|
||||
entry: `place="luna" 1ms 3 KB`,
|
||||
});
|
||||
const displayedFields = getDisplayedFieldsForLogs([log]);
|
||||
|
||||
render(
|
||||
<LogListContextProvider {...contextProps} displayedFields={displayedFields}>
|
||||
<LogLine {...defaultProps} displayedFields={displayedFields} log={log} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
expect(screen.getByText('service=')).toBeInTheDocument();
|
||||
expect(screen.getByText('some service')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('place')).toBeInTheDocument();
|
||||
expect(screen.getByText('1ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('3 KB')).toBeInTheDocument();
|
||||
expect(screen.queryByText(`place="luna" 1ms 3 KB`)).not.toBeInTheDocument();
|
||||
|
||||
config.featureToggles.otelLogsFormatting = originalState;
|
||||
});
|
||||
|
||||
test('OTel attributes field is not present when the flag is disabled', () => {
|
||||
const originalState = config.featureToggles.otelLogsFormatting;
|
||||
config.featureToggles.otelLogsFormatting = false;
|
||||
log = createLogLine({
|
||||
labels: { [OTEL_PROBE_FIELD]: '1', service: 'some service' },
|
||||
entry: `place="luna" 1ms 3 KB`,
|
||||
});
|
||||
|
||||
render(
|
||||
<LogListContextProvider {...contextProps}>
|
||||
<LogLine {...defaultProps} log={log} />
|
||||
</LogListContextProvider>
|
||||
);
|
||||
expect(screen.queryByText('service')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('some service')).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('place')).toBeInTheDocument();
|
||||
expect(screen.getByText('1ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('3 KB')).toBeInTheDocument();
|
||||
expect(screen.queryByText(`place="luna" 1ms 3 KB`)).not.toBeInTheDocument();
|
||||
|
||||
config.featureToggles.otelLogsFormatting = originalState;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Collapsible log lines', () => {
|
||||
|
||||
@@ -20,13 +20,14 @@ import { Button, Icon, Tooltip } from '@grafana/ui';
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { LogLabels } from '../LogLabels';
|
||||
import { LogMessageAnsi } from '../LogMessageAnsi';
|
||||
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats';
|
||||
|
||||
import { HighlightedLogRenderer } from './HighlightedLogRenderer';
|
||||
import { InlineLogLineDetails } from './LogLineDetails';
|
||||
import { LogLineMenu } from './LogLineMenu';
|
||||
import { useLogIsPermalinked, useLogIsPinned, useLogListContext } from './LogListContext';
|
||||
import { useLogListSearchContext } from './LogListSearchContext';
|
||||
import { LogListModel } from './processing';
|
||||
import { getNormalizedFieldName, LogListModel } from './processing';
|
||||
import {
|
||||
FIELD_GAP_MULTIPLIER,
|
||||
getLogLineDOMHeight,
|
||||
@@ -374,6 +375,7 @@ const DisplayedFields = ({
|
||||
styles: LogLineStyles;
|
||||
}) => {
|
||||
const { matchingUids, search } = useLogListSearchContext();
|
||||
const { syntaxHighlighting } = useLogListContext();
|
||||
|
||||
const searchWords = useMemo(() => {
|
||||
const searchWords = log.searchWords && log.searchWords[0] ? log.searchWords.slice() : [];
|
||||
@@ -386,11 +388,19 @@ const DisplayedFields = ({
|
||||
return searchWords;
|
||||
}, [log.searchWords, log.uid, matchingUids, search]);
|
||||
|
||||
return displayedFields.map((field) =>
|
||||
field === LOG_LINE_BODY_FIELD_NAME ? (
|
||||
<LogLineBody log={log} key={field} styles={styles} />
|
||||
) : (
|
||||
<span className="field" title={field} key={field}>
|
||||
return displayedFields.map((field) => {
|
||||
if (field === LOG_LINE_BODY_FIELD_NAME) {
|
||||
return <LogLineBody log={log} key={field} styles={styles} />;
|
||||
}
|
||||
if (field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME && syntaxHighlighting) {
|
||||
return (
|
||||
<span className="field log-syntax-highlight" title={getNormalizedFieldName(field)} key={field}>
|
||||
<HighlightedLogRenderer tokens={log.highlightedLogAttributesTokens} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="field" title={getNormalizedFieldName(field)} key={field}>
|
||||
{searchWords ? (
|
||||
<Highlighter
|
||||
textToHighlight={log.getDisplayedFieldValue(field)}
|
||||
@@ -402,8 +412,8 @@ const DisplayedFields = ({
|
||||
log.getDisplayedFieldValue(field)
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles }) => {
|
||||
@@ -444,7 +454,7 @@ const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles
|
||||
|
||||
return (
|
||||
<span className="field log-syntax-highlight">
|
||||
<HighlightedLogRenderer log={log} />
|
||||
<HighlightedLogRenderer tokens={log.highlightedBodyTokens} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,11 +6,10 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Card, IconButton, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
|
||||
import { LogLineDetailsMode } from './LogLineDetails';
|
||||
import { useLogListContext } from './LogListContext';
|
||||
import { reportInteractionOnce } from './analytics';
|
||||
import { getNormalizedFieldName } from './processing';
|
||||
|
||||
export const LogLineDetailsDisplayedFields = () => {
|
||||
const { displayedFields, setDisplayedFields } = useLogListContext();
|
||||
@@ -98,9 +97,7 @@ const DisplayedField = ({
|
||||
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
|
||||
<Card noMargin className={styles.fieldCard}>
|
||||
<div className={styles.fieldWrapper}>
|
||||
<div className={styles.field}>
|
||||
{field === LOG_LINE_BODY_FIELD_NAME ? t('logs.log-line-details.log-line-field', 'Log line') : field}
|
||||
</div>
|
||||
<div className={styles.field}>{getNormalizedFieldName(field)}</div>
|
||||
{displayedFields.length > 1 && (
|
||||
<>
|
||||
<IconButton
|
||||
|
||||
@@ -12,9 +12,10 @@ import { logRowToSingleRowDataFrame } from '../../logsModel';
|
||||
import { calculateLogsLabelStats, calculateStats } from '../../utils';
|
||||
import { LogLabelStats } from '../LogLabelStats';
|
||||
import { FieldDef } from '../logParser';
|
||||
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats';
|
||||
|
||||
import { useLogListContext } from './LogListContext';
|
||||
import { LogListModel } from './processing';
|
||||
import { LogListModel, getNormalizedFieldName } from './processing';
|
||||
|
||||
interface LogLineDetailsFieldsProps {
|
||||
disableActions?: boolean;
|
||||
@@ -258,12 +259,14 @@ export const LogLineDetailsField = ({
|
||||
const singleKey = keys.length === 1;
|
||||
const singleValue = values.length === 1;
|
||||
|
||||
const fieldSupportsFilters = keys[0] !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.row}>
|
||||
{!disableActions && (
|
||||
<div className={styles.actions}>
|
||||
{onClickFilterLabel && (
|
||||
{onClickFilterLabel && fieldSupportsFilters && (
|
||||
<AsyncIconButton
|
||||
name="search-plus"
|
||||
onClick={filterLabel}
|
||||
@@ -272,7 +275,7 @@ export const LogLineDetailsField = ({
|
||||
tooltipSuffix={refIdTooltip}
|
||||
/>
|
||||
)}
|
||||
{onClickFilterOutLabel && (
|
||||
{onClickFilterOutLabel && fieldSupportsFilters && (
|
||||
<IconButton
|
||||
name="search-minus"
|
||||
tooltip={
|
||||
@@ -313,7 +316,9 @@ export const LogLineDetailsField = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.label}>{singleKey ? keys[0] : <MultipleValue values={keys} />}</div>
|
||||
<div className={styles.label}>
|
||||
{singleKey ? getNormalizedFieldName(keys[0]) : <MultipleValue values={keys} />}
|
||||
</div>
|
||||
<div className={styles.value}>
|
||||
<div className={styles.valueContainer}>
|
||||
{singleValue ? (
|
||||
|
||||
@@ -33,7 +33,9 @@ export const LogLineDetailsLog = memo(({ log: originalLog, syntaxHighlighting }:
|
||||
<>
|
||||
{!syntaxHighlighting && <div className="field no-highlighting">{log.body}</div>}
|
||||
{syntaxHighlighting && (
|
||||
<div className="field log-syntax-highlight">{<HighlightedLogRenderer log={log} />}</div>
|
||||
<div className="field log-syntax-highlight">
|
||||
{<HighlightedLogRenderer tokens={log.highlightedBodyTokens} />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
|
||||
import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils';
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { createLogRow } from '../mocks/logRow';
|
||||
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, OTEL_PROBE_FIELD } from '../otel/formats';
|
||||
|
||||
import { LogList, Props } from './LogList';
|
||||
|
||||
@@ -223,6 +225,67 @@ describe('LogList', () => {
|
||||
expect(screen.getByText('debug')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('OTel log lines', () => {
|
||||
const originalState = config.featureToggles.otelLogsFormatting;
|
||||
|
||||
test('Does not perform OTel-related actions when the flag is disabled', () => {
|
||||
config.featureToggles.otelLogsFormatting = false;
|
||||
const onLogOptionsChange = jest.fn();
|
||||
const setDisplayedFields = jest.fn();
|
||||
|
||||
render(
|
||||
<LogList {...defaultProps} onLogOptionsChange={onLogOptionsChange} setDisplayedFields={setDisplayedFields} />
|
||||
);
|
||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
||||
expect(onLogOptionsChange).not.toHaveBeenCalled();
|
||||
expect(setDisplayedFields).not.toHaveBeenCalled();
|
||||
|
||||
config.featureToggles.otelLogsFormatting = originalState;
|
||||
});
|
||||
|
||||
test('Reports the default displayed fields for non-OTel logs', () => {
|
||||
config.featureToggles.otelLogsFormatting = true;
|
||||
const onLogOptionsChange = jest.fn();
|
||||
const setDisplayedFields = jest.fn();
|
||||
|
||||
render(
|
||||
<LogList {...defaultProps} onLogOptionsChange={onLogOptionsChange} setDisplayedFields={setDisplayedFields} />
|
||||
);
|
||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
||||
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', []);
|
||||
|
||||
// No fields to display, no call
|
||||
expect(setDisplayedFields).not.toHaveBeenCalled();
|
||||
|
||||
config.featureToggles.otelLogsFormatting = originalState;
|
||||
});
|
||||
|
||||
test('Reports the default OTel displayed fields', () => {
|
||||
config.featureToggles.otelLogsFormatting = true;
|
||||
const onLogOptionsChange = jest.fn();
|
||||
const setDisplayedFields = jest.fn();
|
||||
|
||||
const logs = [createLogRow({ uid: '1', labels: { [OTEL_PROBE_FIELD]: '1' } })];
|
||||
|
||||
render(
|
||||
<LogList
|
||||
{...defaultProps}
|
||||
logs={logs}
|
||||
onLogOptionsChange={onLogOptionsChange}
|
||||
setDisplayedFields={setDisplayedFields}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('log message 1')).toBeInTheDocument();
|
||||
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', [
|
||||
LOG_LINE_BODY_FIELD_NAME,
|
||||
OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME,
|
||||
]);
|
||||
expect(setDisplayedFields).toHaveBeenCalledWith([LOG_LINE_BODY_FIELD_NAME, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]);
|
||||
|
||||
config.featureToggles.otelLogsFormatting = originalState;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Popover menu', () => {
|
||||
function setup(overrides: Partial<Props> = {}) {
|
||||
return render(
|
||||
|
||||
@@ -66,7 +66,7 @@ export interface Props {
|
||||
onClickFilterOutString?: (value: string, refId?: string) => void;
|
||||
onClickShowField?: (key: string) => void;
|
||||
onClickHideField?: (key: string) => void;
|
||||
onLogOptionsChange?: (option: LogListControlOptions, value: string | boolean | string[]) => void;
|
||||
onLogOptionsChange?: (option: LogListOptions, value: string | boolean | string[]) => void;
|
||||
onLogLineHover?: (row?: LogRowModel) => void;
|
||||
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
|
||||
onPinLine?: (row: LogRowModel) => void;
|
||||
@@ -78,6 +78,11 @@ export interface Props {
|
||||
prettifyJSON?: boolean;
|
||||
setDisplayedFields?: (displayedFields: string[]) => void;
|
||||
showControls: boolean;
|
||||
/**
|
||||
* Experimental. When OTel logs are displayed, add an extra displayed field with relevant key-value pairs from labels and metadata
|
||||
* @alpha
|
||||
*/
|
||||
showLogAttributes?: boolean;
|
||||
showTime: boolean;
|
||||
showUniqueLabels?: boolean;
|
||||
sortOrder: LogsSortOrder;
|
||||
@@ -90,7 +95,7 @@ export interface Props {
|
||||
|
||||
export type LogListFontSize = 'default' | 'small';
|
||||
|
||||
export type LogListControlOptions = keyof LogListState | 'wrapLogMessage' | 'prettifyLogMessage';
|
||||
export type LogListOptions = keyof LogListState | 'wrapLogMessage' | 'prettifyLogMessage' | 'defaultDisplayedFields';
|
||||
|
||||
type LogListComponentProps = Omit<
|
||||
Props,
|
||||
@@ -148,6 +153,7 @@ export const LogList = ({
|
||||
prettifyJSON = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.prettifyLogMessage`, true) : true,
|
||||
setDisplayedFields,
|
||||
showControls,
|
||||
showLogAttributes,
|
||||
showTime,
|
||||
showUniqueLabels,
|
||||
sortOrder,
|
||||
@@ -193,6 +199,7 @@ export const LogList = ({
|
||||
prettifyJSON={prettifyJSON}
|
||||
setDisplayedFields={setDisplayedFields}
|
||||
showControls={showControls}
|
||||
showLogAttributes={showLogAttributes}
|
||||
showTime={showTime}
|
||||
showUniqueLabels={showUniqueLabels}
|
||||
sortOrder={sortOrder}
|
||||
|
||||
@@ -33,7 +33,7 @@ import { getDisplayedFieldsForLogs } from '../otel/formats';
|
||||
import { LogLineTimestampResolution } from './LogLine';
|
||||
import { LogLineDetailsMode } from './LogLineDetails';
|
||||
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
|
||||
import { LogListControlOptions, LogListFontSize } from './LogList';
|
||||
import { LogListOptions, LogListFontSize } from './LogList';
|
||||
import { reportInteractionOnce } from './analytics';
|
||||
import { LogListModel } from './processing';
|
||||
import { getScrollbarWidth, LOG_LIST_CONTROLS_WIDTH, LOG_LIST_MIN_WIDTH } from './virtualization';
|
||||
@@ -176,7 +176,7 @@ export interface Props {
|
||||
onClickFilterOutString?: (value: string, refId?: string) => void;
|
||||
onClickShowField?: (key: string) => void;
|
||||
onClickHideField?: (key: string) => void;
|
||||
onLogOptionsChange?: (option: LogListControlOptions, value: string | boolean | string[]) => void;
|
||||
onLogOptionsChange?: (option: LogListOptions, value: string | boolean | string[]) => void;
|
||||
onLogLineHover?: (row?: LogRowModel) => void;
|
||||
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
|
||||
onPinLine?: (row: LogRowModel) => void;
|
||||
@@ -188,6 +188,7 @@ export interface Props {
|
||||
prettifyJSON?: boolean;
|
||||
setDisplayedFields?: (displayedFields: string[]) => void;
|
||||
showControls: boolean;
|
||||
showLogAttributes?: boolean;
|
||||
showUniqueLabels?: boolean;
|
||||
showTime: boolean;
|
||||
sortOrder: LogsSortOrder;
|
||||
@@ -234,6 +235,7 @@ export const LogListContextProvider = ({
|
||||
prettifyJSON: prettifyJSONProp,
|
||||
setDisplayedFields,
|
||||
showControls,
|
||||
showLogAttributes,
|
||||
showTime,
|
||||
showUniqueLabels,
|
||||
sortOrder,
|
||||
@@ -289,16 +291,28 @@ export const LogListContextProvider = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const otelDisplayedFields = useMemo(() => {
|
||||
if (!config.featureToggles.otelLogsFormatting || !setDisplayedFields || showLogAttributes === false) {
|
||||
return [];
|
||||
}
|
||||
return getDisplayedFieldsForLogs(logs);
|
||||
}, [logs, setDisplayedFields, showLogAttributes]);
|
||||
|
||||
// OTel displayed fields
|
||||
useEffect(() => {
|
||||
if (displayedFields.length > 0 || !config.featureToggles.otelLogsFormatting || !setDisplayedFields) {
|
||||
if (config.featureToggles.otelLogsFormatting && showLogAttributes !== false) {
|
||||
onLogOptionsChange?.('defaultDisplayedFields', otelDisplayedFields);
|
||||
}
|
||||
}, [onLogOptionsChange, otelDisplayedFields, showLogAttributes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (displayedFields.length > 0 || !setDisplayedFields) {
|
||||
return;
|
||||
}
|
||||
const otelDisplayedFields = getDisplayedFieldsForLogs(logs);
|
||||
if (otelDisplayedFields.length) {
|
||||
setDisplayedFields(otelDisplayedFields);
|
||||
}
|
||||
}, [displayedFields.length, logs, setDisplayedFields]);
|
||||
}, [displayedFields.length, otelDisplayedFields, setDisplayedFields]);
|
||||
|
||||
// Sync state
|
||||
useEffect(() => {
|
||||
@@ -404,6 +418,13 @@ export const LogListContextProvider = ({
|
||||
}));
|
||||
}, [timestampResolution]);
|
||||
|
||||
// Sync showLogAttributes
|
||||
useEffect(() => {
|
||||
if (showLogAttributes === false && setDisplayedFields) {
|
||||
setDisplayedFields([]);
|
||||
}
|
||||
}, [setDisplayedFields, showLogAttributes]);
|
||||
|
||||
const controlsExpandedFromStore = store.getBool(
|
||||
`${logOptionsStorageKey}.controlsExpanded`,
|
||||
getDefaultControlsExpandedMode(containerElement ?? null)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { config } from '@grafana/runtime';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { createLogLine, createLogRow } from '../mocks/logRow';
|
||||
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, OTEL_PROBE_FIELD } from '../otel/formats';
|
||||
|
||||
import { LogListFontSize } from './LogList';
|
||||
import { LogListModel, preProcessLogs } from './processing';
|
||||
@@ -237,6 +238,67 @@ describe('preProcessLogs', () => {
|
||||
expect(logListModel.body).toBeDefined(); // Triggers parsing
|
||||
expect(logListModel.isJSON).toBe(false);
|
||||
});
|
||||
|
||||
describe('OTel logs', () => {
|
||||
const originalState = config.featureToggles.otelLogsFormatting;
|
||||
|
||||
test('Does not create the OTel attribute field when not enabled', () => {
|
||||
config.featureToggles.otelLogsFormatting = false;
|
||||
|
||||
const logListModel = createLogLine(
|
||||
{ entry: 'the log' },
|
||||
{
|
||||
escape: false,
|
||||
order: LogsSortOrder.Descending,
|
||||
timeZone: 'browser',
|
||||
wrapLogMessage: true, // wrapped
|
||||
prettifyJSON: true,
|
||||
}
|
||||
);
|
||||
expect(logListModel.labels[OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]).toBeUndefined();
|
||||
expect(logListModel.highlightedLogAttributesTokens).toHaveLength(0);
|
||||
|
||||
config.featureToggles.otelLogsFormatting = originalState;
|
||||
});
|
||||
|
||||
test('Does not create the OTel attribute field when is not an OTel log', () => {
|
||||
config.featureToggles.otelLogsFormatting = false;
|
||||
|
||||
const logListModel = createLogLine(
|
||||
{ entry: 'the log', labels: {} },
|
||||
{
|
||||
escape: false,
|
||||
order: LogsSortOrder.Descending,
|
||||
timeZone: 'browser',
|
||||
wrapLogMessage: true, // wrapped
|
||||
prettifyJSON: true,
|
||||
}
|
||||
);
|
||||
expect(logListModel.labels[OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]).toBeUndefined();
|
||||
expect(logListModel.highlightedLogAttributesTokens).toHaveLength(0);
|
||||
|
||||
config.featureToggles.otelLogsFormatting = originalState;
|
||||
});
|
||||
|
||||
test('Generates and highlights an OTel log line attributes field', () => {
|
||||
config.featureToggles.otelLogsFormatting = true;
|
||||
|
||||
const logListModel = createLogLine(
|
||||
{ entry: 'the log', labels: { [OTEL_PROBE_FIELD]: '1', field: 'value' } },
|
||||
{
|
||||
escape: false,
|
||||
order: LogsSortOrder.Descending,
|
||||
timeZone: 'browser',
|
||||
wrapLogMessage: true, // wrapped
|
||||
prettifyJSON: true,
|
||||
}
|
||||
);
|
||||
expect(logListModel.labels[OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]).toEqual('field=value');
|
||||
expect(logListModel.highlightedLogAttributesTokens).toHaveLength(2);
|
||||
|
||||
config.featureToggles.otelLogsFormatting = originalState;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('Orders logs', () => {
|
||||
@@ -440,43 +502,3 @@ describe('preProcessLogs', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OTel logs', () => {
|
||||
let originalOtelLogsFormatting = config.featureToggles.otelLogsFormatting;
|
||||
afterAll(() => {
|
||||
config.featureToggles.otelLogsFormatting = originalOtelLogsFormatting;
|
||||
});
|
||||
|
||||
test('Requires a feature flag', () => {
|
||||
const log = createLogLine({
|
||||
labels: {
|
||||
severity_number: '1',
|
||||
telemetry_sdk_language: 'php',
|
||||
scope_name: 'scope',
|
||||
aws_ignore: 'ignored',
|
||||
key: 'value',
|
||||
otel: 'otel',
|
||||
},
|
||||
entry: `place="luna" 1ms 3 KB`,
|
||||
});
|
||||
expect(log.otelLanguage).toBeDefined();
|
||||
expect(log.body).toEqual(`place="luna" 1ms 3 KB`);
|
||||
});
|
||||
|
||||
test('Augments OTel log lines', () => {
|
||||
config.featureToggles.otelLogsFormatting = true;
|
||||
const log = createLogLine({
|
||||
labels: {
|
||||
severity_number: '1',
|
||||
telemetry_sdk_language: 'php',
|
||||
scope_name: 'scope',
|
||||
aws_ignore: 'ignored',
|
||||
key: 'value',
|
||||
otel: 'otel',
|
||||
},
|
||||
entry: `place="luna" 1ms 3 KB`,
|
||||
});
|
||||
expect(log.otelLanguage).toBeDefined();
|
||||
expect(log.body).toEqual(`place="luna" 1ms 3 KB key=value otel=otel`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,19 +11,20 @@ import {
|
||||
LogsSortOrder,
|
||||
systemDateFormats,
|
||||
} from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
|
||||
|
||||
import { checkLogsError, checkLogsSampled, escapeUnescapedString, sortLogRows } from '../../utils';
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { FieldDef, getAllFields } from '../logParser';
|
||||
import { identifyOTelLanguage, getOtelFormattedBody } from '../otel/formats';
|
||||
import { identifyOTelLanguage, getOtelAttributesField, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats';
|
||||
|
||||
import { generateLogGrammar, generateTextMatchGrammar } from './grammar';
|
||||
import { LogLineVirtualization } from './virtualization';
|
||||
|
||||
const TRUNCATION_DEFAULT_LENGTH = 50000;
|
||||
const NEWLINES_REGEX = /(\r\n|\n|\r)/g;
|
||||
export const NEWLINES_REGEX = /(\r\n|\n|\r)/g;
|
||||
|
||||
export class LogListModel implements LogRowModel {
|
||||
collapsed: boolean | undefined = undefined;
|
||||
@@ -59,6 +60,7 @@ export class LogListModel implements LogRowModel {
|
||||
private _currentSearch: string | undefined = undefined;
|
||||
private _grammar?: Grammar;
|
||||
private _highlightedBody: string | undefined = undefined;
|
||||
private _highlightedLogAttributesTokens: Array<string | Token> | undefined = undefined;
|
||||
private _highlightTokens: Array<string | Token> | undefined = undefined;
|
||||
private _fields: FieldDef[] | undefined = undefined;
|
||||
private _getFieldLinks: GetFieldLinksFn | undefined = undefined;
|
||||
@@ -114,6 +116,10 @@ export class LogListModel implements LogRowModel {
|
||||
raw = escapeUnescapedString(raw);
|
||||
}
|
||||
this.raw = raw;
|
||||
|
||||
if (config.featureToggles.otelLogsFormatting && this.otelLanguage) {
|
||||
this.labels[OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME] = getOtelAttributesField(this, wrapLogMessage);
|
||||
}
|
||||
}
|
||||
|
||||
clone() {
|
||||
@@ -137,7 +143,7 @@ export class LogListModel implements LogRowModel {
|
||||
this.raw = reStringified;
|
||||
}
|
||||
} catch (error) {}
|
||||
const raw = config.featureToggles.otelLogsFormatting && this.otelLanguage ? getOtelFormattedBody(this) : this.raw;
|
||||
const raw = this.raw;
|
||||
this._body = this.collapsed
|
||||
? raw.substring(0, this._virtualization?.getTruncationLength(null) ?? TRUNCATION_DEFAULT_LENGTH)
|
||||
: raw;
|
||||
@@ -181,6 +187,19 @@ export class LogListModel implements LogRowModel {
|
||||
return this._highlightTokens;
|
||||
}
|
||||
|
||||
get highlightedLogAttributesTokens() {
|
||||
if (this._highlightedLogAttributesTokens === undefined) {
|
||||
const attributes = this.labels[OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME] ?? '';
|
||||
if (!attributes) {
|
||||
return [];
|
||||
}
|
||||
this._grammar = this._grammar ?? generateLogGrammar(this);
|
||||
const extraGrammar = generateTextMatchGrammar(this.searchWords, this._currentSearch);
|
||||
this._highlightedLogAttributesTokens = Prism.tokenize(attributes, { ...extraGrammar, ...this._grammar });
|
||||
}
|
||||
return this._highlightedLogAttributesTokens;
|
||||
}
|
||||
|
||||
get isJSON() {
|
||||
return this._json;
|
||||
}
|
||||
@@ -250,6 +269,7 @@ export class LogListModel implements LogRowModel {
|
||||
setCurrentSearch(search: string | undefined) {
|
||||
this._currentSearch = search;
|
||||
this._highlightTokens = undefined;
|
||||
this._highlightedLogAttributesTokens = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,3 +355,12 @@ export function getLevelsFromLogs(logs: LogListModel[]) {
|
||||
}
|
||||
return Array.from(levels).filter((level) => level != null);
|
||||
}
|
||||
|
||||
export function getNormalizedFieldName(field: string) {
|
||||
if (field === LOG_LINE_BODY_FIELD_NAME) {
|
||||
return t('logs.log-line-details.log-line-field', 'Log line');
|
||||
} else if (field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME) {
|
||||
return t('logs.log-line-details.log-attributes-field', 'OTel attributes');
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { LogLabels } from '../../../features/logs/components/LogLabels';
|
||||
import { LogRows } from '../../../features/logs/components/LogRows';
|
||||
import { COMMON_LABELS, dataFrameToLogsModel, dedupLogRows } from '../../../features/logs/logsModel';
|
||||
|
||||
import type { Options } from './panelcfg.gen';
|
||||
import {
|
||||
GetFieldLinksFn,
|
||||
isCoreApp,
|
||||
@@ -63,7 +64,6 @@ import {
|
||||
isReactNodeArray,
|
||||
isSetDisplayedFields,
|
||||
onNewLogsReceivedType,
|
||||
Options,
|
||||
} from './types';
|
||||
import { useDatasourcesFromTargets } from './useDatasourcesFromTargets';
|
||||
|
||||
@@ -114,7 +114,7 @@ interface LogsPanelProps extends PanelProps<Options> {
|
||||
* controlsStorageKey?: string
|
||||
*
|
||||
* If controls are enabled, this function is called when a change is made in one of the options from the controls.
|
||||
* onLogOptionsChange?: (option: LogListControlOptions, value: string | boolean | string[]) => void;
|
||||
* onLogOptionsChange?: (option: LogListOptions, value: string | boolean | string[]) => void;
|
||||
*
|
||||
* When the feature toggle newLogsPanel is enabled, you can pass extra options to the LogLineMenu component.
|
||||
* These options are an array of items with { label, onClick } or { divider: true } for dividers.
|
||||
@@ -128,6 +128,11 @@ interface LogsPanelProps extends PanelProps<Options> {
|
||||
*
|
||||
* When showing timestamps, toggle between showing nanoseconds or milliseconds.
|
||||
* timestampResolution?: 'ms' | 'ns'
|
||||
*
|
||||
* Experimental. When OTel logs are displayed, add an extra displayed field with relevant key-value pairs from labels and metadata.
|
||||
* Requires the `otelLogsFormatting`.
|
||||
* @alpha
|
||||
* showLogAttributes?: boolean
|
||||
*/
|
||||
}
|
||||
interface LogsPermalinkUrlState {
|
||||
@@ -170,6 +175,7 @@ export const LogsPanel = ({
|
||||
detailsMode: detailsModeProp,
|
||||
noInteractions,
|
||||
timestampResolution,
|
||||
showLogAttributes,
|
||||
...options
|
||||
},
|
||||
height,
|
||||
@@ -609,6 +615,7 @@ export const LogsPanel = ({
|
||||
prettifyJSON={prettifyLogMessage}
|
||||
setDisplayedFields={setDisplayedFieldsFn}
|
||||
showControls={Boolean(showControls)}
|
||||
showLogAttributes={showLogAttributes}
|
||||
showTime={showTime}
|
||||
showUniqueLabels={showLabels}
|
||||
sortOrder={sortOrder}
|
||||
|
||||
@@ -119,6 +119,19 @@ export const plugin = new PanelPlugin<Options>(LogsPanel)
|
||||
defaultValue: false,
|
||||
});
|
||||
|
||||
if (config.featureToggles.otelLogsFormatting) {
|
||||
builder.addBooleanSwitch({
|
||||
path: 'showLogAttributes',
|
||||
name: t('logs.show-log-attributes', 'Display log attributes for OTel logs'),
|
||||
category,
|
||||
description: t(
|
||||
'logs.description-show-log-attributes',
|
||||
'Experimental. When OTel logs are displayed, add an extra displayed field with relevant key-value pairs from labels and metadata.'
|
||||
),
|
||||
defaultValue: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (config.featureToggles.newLogsPanel) {
|
||||
builder
|
||||
.addBooleanSwitch({
|
||||
|
||||
@@ -40,6 +40,7 @@ composableKinds: PanelCfg: {
|
||||
dedupStrategy: common.LogsDedupStrategy
|
||||
enableInfiniteScrolling?: bool
|
||||
noInteractions?: bool
|
||||
showLogAttributes?: bool
|
||||
fontSize?: "default" | "small" @cuetsy(kind="enum", memberNames="default|small")
|
||||
detailsMode?: "inline" | "sidebar" @cuetsy(kind="enum", memberNames="inline|sidebar")
|
||||
timestampResolution?: "ms" | "ns" @cuetsy(kind="enum", memberNames="ms|ns")
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface Options {
|
||||
showCommonLabels: boolean;
|
||||
showControls?: boolean;
|
||||
showLabels: boolean;
|
||||
showLogAttributes?: boolean;
|
||||
showLogContextToggle: boolean;
|
||||
showTime: boolean;
|
||||
sortOrder: common.LogsSortOrder;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data';
|
||||
import { SuggestionName } from 'app/types/suggestions';
|
||||
|
||||
import { Options } from './types';
|
||||
import { Options } from './panelcfg.gen';
|
||||
|
||||
export class LogsPanelSuggestionsSupplier {
|
||||
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
|
||||
|
||||
@@ -2,9 +2,7 @@ import React, { ReactNode } from 'react';
|
||||
|
||||
import { CoreApp, DataFrame, Field, LinkModel, ScopedVars } from '@grafana/data';
|
||||
import { LogLineMenuCustomItem } from 'app/features/logs/components/panel/LogLineMenu';
|
||||
import { LogListControlOptions } from 'app/features/logs/components/panel/LogList';
|
||||
|
||||
export type { Options } from './panelcfg.gen';
|
||||
import { LogListOptions } from 'app/features/logs/components/panel/LogList';
|
||||
|
||||
type onClickFilterLabelType = (key: string, value: string, frame?: DataFrame) => void;
|
||||
type onClickFilterOutLabelType = (key: string, value: string, frame?: DataFrame) => void;
|
||||
@@ -14,7 +12,7 @@ type filterLabelActiveType = (key: string, value: string, refId?: string) => Pro
|
||||
type onClickShowFieldType = (value: string) => void;
|
||||
type onClickHideFieldType = (value: string) => void;
|
||||
export type onNewLogsReceivedType = (allLogs: DataFrame[], newLogs: DataFrame[]) => void;
|
||||
type onLogOptionsChangeType = (option: LogListControlOptions, value: string | boolean | string[]) => void;
|
||||
type onLogOptionsChangeType = (option: LogListOptions, value: string | boolean | string[]) => void;
|
||||
type setDisplayedFieldsType = (fields: string[]) => void;
|
||||
|
||||
export type GetFieldLinksFn = (
|
||||
|
||||
@@ -9446,6 +9446,7 @@
|
||||
"description-enable-infinite-scrolling": "Experimental. Request more results by scrolling to the bottom of the logs list.",
|
||||
"description-enable-logs-highlighting": "Use a predefined coloring scheme to highlight relevant parts of the log lines",
|
||||
"description-show-controls": "Display controls to jump to the last or first log line, and filters by log level",
|
||||
"description-show-log-attributes": "Experimental. When OTel logs are displayed, add an extra displayed field with relevant key-value pairs from labels and metadata.",
|
||||
"fields": {
|
||||
"type": {
|
||||
"loki": {
|
||||
@@ -9496,9 +9497,6 @@
|
||||
"collapse": "Collapse labels",
|
||||
"expand": "Expand labels"
|
||||
},
|
||||
"log-labels-list": {
|
||||
"log-line": "log line"
|
||||
},
|
||||
"log-line": {
|
||||
"has-error": "Has errors",
|
||||
"is-sampled": "Is sampled",
|
||||
@@ -9538,6 +9536,7 @@
|
||||
"inline-mode": "Display inline",
|
||||
"link-value-tooltip": "Link value",
|
||||
"links-section": "Links",
|
||||
"log-attributes-field": "OTel attributes",
|
||||
"log-line-field": "Log line",
|
||||
"log-line-section": "Log line",
|
||||
"move-displayed-field-down": "Move down",
|
||||
@@ -9744,6 +9743,7 @@
|
||||
"line-contains": "Add as line contains filter",
|
||||
"line-contains-not": "Add as line does not contain filter"
|
||||
},
|
||||
"show-log-attributes": "Display log attributes for OTel logs",
|
||||
"timestamp-format": "Timestamp resolution",
|
||||
"un-themed-log-details": {
|
||||
"aria-label-data-links": "Data links",
|
||||
|
||||
Reference in New Issue
Block a user