From a64af689553904acd3d0108ad9bd50e600d8b868 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Tue, 7 Oct 2025 13:19:27 +0200 Subject: [PATCH] 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 --- eslint-suppressions.json | 5 - .../logs/panelcfg/x/LogsPanelCfg_types.gen.ts | 1 + public/app/features/explore/Logs/Logs.tsx | 52 ++++----- .../explore/Logs/LogsMetaRow.test.tsx | 5 +- .../app/features/explore/Logs/LogsMetaRow.tsx | 30 ++++-- .../explore/Logs/LogsTableAvailableFields.tsx | 5 +- .../logs/components/ControlledLogRows.tsx | 4 +- .../logs/components/LogLabels.test.tsx | 3 +- .../features/logs/components/LogLabels.tsx | 4 +- .../logs/components/otel/formats.test.ts | 97 +++++++++++++---- .../features/logs/components/otel/formats.ts | 48 ++++++--- .../panel/HighlightedLogRenderer.test.tsx | 6 +- .../panel/HighlightedLogRenderer.tsx | 15 +-- .../logs/components/panel/LogLine.test.tsx | 51 +++++++++ .../logs/components/panel/LogLine.tsx | 28 +++-- .../panel/LogLineDetailsDisplayedFields.tsx | 7 +- .../components/panel/LogLineDetailsFields.tsx | 13 ++- .../components/panel/LogLineDetailsLog.tsx | 4 +- .../logs/components/panel/LogList.test.tsx | 63 +++++++++++ .../logs/components/panel/LogList.tsx | 11 +- .../logs/components/panel/LogListContext.tsx | 31 +++++- .../logs/components/panel/processing.test.ts | 102 +++++++++++------- .../logs/components/panel/processing.ts | 35 +++++- public/app/plugins/panel/logs/LogsPanel.tsx | 11 +- public/app/plugins/panel/logs/module.tsx | 13 +++ public/app/plugins/panel/logs/panelcfg.cue | 1 + public/app/plugins/panel/logs/panelcfg.gen.ts | 1 + public/app/plugins/panel/logs/suggestions.ts | 2 +- public/app/plugins/panel/logs/types.ts | 6 +- public/locales/en-US/grafana.json | 6 +- 30 files changed, 486 insertions(+), 174 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 3e9f4e14c96..c69363ff210 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts index 4fc8ccf355a..90b4c7194f1 100644 --- a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts @@ -41,6 +41,7 @@ export interface Options { showCommonLabels: boolean; showControls?: boolean; showLabels: boolean; + showLogAttributes?: boolean; showLogContextToggle: boolean; showTime: boolean; sortOrder: common.LogsSortOrder; diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 3c505a5aeea..56076abfcce 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -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) => { store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending ); const [isFlipping, setIsFlipping] = useState(false); - const [displayedFields, setDisplayedFields] = useState([]); + const [displayedFields, setDisplayedFields] = useState(panelState?.logs?.displayedFields ?? []); + const [defaultDisplayedFields, setDefaultDisplayedFields] = useState([]); const [contextOpen, setContextOpen] = useState(false); const [contextRow, setContextRow] = useState(undefined); const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState(PINNED_LOGS_MESSAGE); @@ -280,16 +282,6 @@ const UnthemedLogs: React.FunctionComponent = (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) => { ] ); + 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) => { 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) => { 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) => { 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) => { 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) => { dedupStrategy={dedupStrategy} dedupCount={dedupCount} displayedFields={displayedFields} - clearDetectedFields={clearDetectedFields} + clearDisplayedFields={clearDisplayedFields} + defaultDisplayedFields={defaultDisplayedFields} />
diff --git a/public/app/features/explore/Logs/LogsMetaRow.test.tsx b/public/app/features/explore/Logs/LogsMetaRow.test.tsx index 598b7cebb7a..ea0e65f91cb 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.test.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.test.tsx @@ -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', diff --git a/public/app/features/explore/Logs/LogsMetaRow.tsx b/public/app/features/explore/Logs/LogsMetaRow.tsx index 96206ea3858..04673a11de2 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.tsx @@ -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 = [...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: ( - ), } diff --git a/public/app/features/explore/Logs/LogsTableAvailableFields.tsx b/public/app/features/explore/Logs/LogsTableAvailableFields.tsx index f203b3b48a2..2fff4a27fff 100644 --- a/public/app/features/explore/Logs/LogsTableAvailableFields.tsx +++ b/public/app/features/explore/Logs/LogsTableAvailableFields.tsx @@ -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 ( diff --git a/public/app/features/logs/components/ControlledLogRows.tsx b/public/app/features/logs/components/ControlledLogRows.tsx index 13e57b3781f..ee663e3d0d9 100644 --- a/public/app/features/logs/components/ControlledLogRows.tsx +++ b/public/app/features/logs/components/ControlledLogRows.tsx @@ -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 { 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[]; diff --git a/public/app/features/logs/components/LogLabels.test.tsx b/public/app/features/logs/components/LogLabels.test.tsx index b70af572021..a5ac1219da9 100644 --- a/public/app/features/logs/components/LogLabels.test.tsx +++ b/public/app/features/logs/components/LogLabels.test.tsx @@ -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('', () => { it('renders notice when no labels are found', () => { @@ -96,6 +97,6 @@ describe('', () => { render(); 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(); }); }); diff --git a/public/app/features/logs/components/LogLabels.tsx b/public/app/features/logs/components/LogLabels.tsx index da8107663fb..c112eee4f83 100644 --- a/public/app/features/logs/components/LogLabels.tsx +++ b/public/app/features/logs/components/LogLabels.tsx @@ -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) => { {labels.map((label) => ( - {label === LOG_LINE_BODY_FIELD_NAME ? t('logs.log-labels-list.log-line', 'log line') : label} + {getNormalizedFieldName(label)} ))} diff --git a/public/app/features/logs/components/otel/formats.test.ts b/public/app/features/logs/components/otel/formats.test.ts index 4844e2a307a..0da98ea9b5e 100644 --- a/public/app/features/logs/components/otel/formats.test.ts +++ b/public/app/features/logs/components/otel/formats.test.ts @@ -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'); }); }); diff --git a/public/app/features/logs/components/otel/formats.ts b/public/app/features/logs/components/otel/formats.ts index dc28b49e52a..6f870325231 100644 --- a/public/app/features/logs/components/otel/formats.ts +++ b/public/app/features/logs/components/otel/formats.ts @@ -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(); 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; } diff --git a/public/app/features/logs/components/panel/HighlightedLogRenderer.test.tsx b/public/app/features/logs/components/panel/HighlightedLogRenderer.test.tsx index 272bb0e2ade..99c0d5fcf96 100644 --- a/public/app/features/logs/components/panel/HighlightedLogRenderer.test.tsx +++ b/public/app/features/logs/components/panel/HighlightedLogRenderer.test.tsx @@ -34,7 +34,7 @@ describe('HighlightedLogRenderer', () => { } ); - const { container } = render(); + const { container } = render(); expect(container.innerHTML).toEqual(log.highlightedBody); }); @@ -177,7 +177,7 @@ describe('HighlightedLogRenderer', () => { } ); - const { container } = render(); + const { container } = render(); expect(container.innerHTML).toEqual(log.highlightedBody); }); @@ -201,7 +201,7 @@ describe('HighlightedLogRenderer', () => { } ); - const { container } = render(); + const { container } = render(); expect(container.innerHTML).toEqual(log.highlightedBody); }); diff --git a/public/app/features/logs/components/panel/HighlightedLogRenderer.tsx b/public/app/features/logs/components/panel/HighlightedLogRenderer.tsx index 2016bc84cbc..4a59b1e6d81 100644 --- a/public/app/features/logs/components/panel/HighlightedLogRenderer.tsx +++ b/public/app/features/logs/components/panel/HighlightedLogRenderer.tsx @@ -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 }) => { return ( <> - {log.highlightedBodyTokens.map((token, i) => ( + {tokens.map((token, 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.displayName = 'LogToken'; diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index b79bf06e2e0..6e4b1599398 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -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( + + + + ); + 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( + + + + ); + 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', () => { diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index 62a19356407..d58a861439e 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -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 ? ( - - ) : ( - + return displayedFields.map((field) => { + if (field === LOG_LINE_BODY_FIELD_NAME) { + return ; + } + if (field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME && syntaxHighlighting) { + return ( + + + + ); + } + return ( + {searchWords ? ( - ) - ); + ); + }); }; const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles }) => { @@ -444,7 +454,7 @@ const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles return ( - + ); }; diff --git a/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx b/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx index e5bce32bd86..24a0c67948b 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx @@ -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 = ({
-
- {field === LOG_LINE_BODY_FIELD_NAME ? t('logs.log-line-details.log-line-field', 'Log line') : field} -
+
{getNormalizedFieldName(field)}
{displayedFields.length > 1 && ( <>
{!disableActions && (
- {onClickFilterLabel && ( + {onClickFilterLabel && fieldSupportsFilters && ( )} - {onClickFilterOutLabel && ( + {onClickFilterOutLabel && fieldSupportsFilters && (
)} -
{singleKey ? keys[0] : }
+
+ {singleKey ? getNormalizedFieldName(keys[0]) : } +
{singleValue ? ( diff --git a/public/app/features/logs/components/panel/LogLineDetailsLog.tsx b/public/app/features/logs/components/panel/LogLineDetailsLog.tsx index 1f8955c44cb..28ecad3d8a3 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsLog.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsLog.tsx @@ -33,7 +33,9 @@ export const LogLineDetailsLog = memo(({ log: originalLog, syntaxHighlighting }: <> {!syntaxHighlighting &&
{log.body}
} {syntaxHighlighting && ( -
{}
+
+ {} +
)} )} diff --git a/public/app/features/logs/components/panel/LogList.test.tsx b/public/app/features/logs/components/panel/LogList.test.tsx index 572ff363097..7f292ab7216 100644 --- a/public/app/features/logs/components/panel/LogList.test.tsx +++ b/public/app/features/logs/components/panel/LogList.test.tsx @@ -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( + + ); + 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( + + ); + 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( + + ); + 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 = {}) { return render( diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 1986a9d2e57..055539ed985 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -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; 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} diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index bac7d507652..9ec63e2765d 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -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; 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) diff --git a/public/app/features/logs/components/panel/processing.test.ts b/public/app/features/logs/components/panel/processing.test.ts index 0b69cd15f7a..1e8cf516325 100644 --- a/public/app/features/logs/components/panel/processing.test.ts +++ b/public/app/features/logs/components/panel/processing.test.ts @@ -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`); - }); -}); diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index 98a0cf6a00c..7ad570acad8 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -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 | undefined = undefined; private _highlightTokens: Array | 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; +} diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index ac03ed51932..cf309b219b3 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -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 { * 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 { * * 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} diff --git a/public/app/plugins/panel/logs/module.tsx b/public/app/plugins/panel/logs/module.tsx index e73604b13ea..04ed5e92183 100644 --- a/public/app/plugins/panel/logs/module.tsx +++ b/public/app/plugins/panel/logs/module.tsx @@ -119,6 +119,19 @@ export const plugin = new PanelPlugin(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({ diff --git a/public/app/plugins/panel/logs/panelcfg.cue b/public/app/plugins/panel/logs/panelcfg.cue index 44eb486d3a0..a25eee69a8f 100644 --- a/public/app/plugins/panel/logs/panelcfg.cue +++ b/public/app/plugins/panel/logs/panelcfg.cue @@ -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") diff --git a/public/app/plugins/panel/logs/panelcfg.gen.ts b/public/app/plugins/panel/logs/panelcfg.gen.ts index ad5a194f733..58944c7f5f8 100644 --- a/public/app/plugins/panel/logs/panelcfg.gen.ts +++ b/public/app/plugins/panel/logs/panelcfg.gen.ts @@ -39,6 +39,7 @@ export interface Options { showCommonLabels: boolean; showControls?: boolean; showLabels: boolean; + showLogAttributes?: boolean; showLogContextToggle: boolean; showTime: boolean; sortOrder: common.LogsSortOrder; diff --git a/public/app/plugins/panel/logs/suggestions.ts b/public/app/plugins/panel/logs/suggestions.ts index 6f7daa57c05..79b804cb8d7 100644 --- a/public/app/plugins/panel/logs/suggestions.ts +++ b/public/app/plugins/panel/logs/suggestions.ts @@ -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) { diff --git a/public/app/plugins/panel/logs/types.ts b/public/app/plugins/panel/logs/types.ts index bd369daa388..3b9d0450414 100644 --- a/public/app/plugins/panel/logs/types.ts +++ b/public/app/plugins/panel/logs/types.ts @@ -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 = ( diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 2c9439a2df2..2fa137fe730 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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",