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:
Matias Chomicki
2025-10-07 11:19:27 +00:00
committed by GitHub
parent 2cbf2c071a
commit a64af68955
30 changed files with 486 additions and 174 deletions
-5
View File
@@ -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;
+22 -30
View File
@@ -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;
}
+9 -2
View File
@@ -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}
+13
View File
@@ -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 -1
View File
@@ -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 -4
View File
@@ -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 = (
+3 -3
View File
@@ -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",