From f77e8839d9ae11c2c3ffc220e2ef6f91f775fd84 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Fri, 8 Aug 2025 19:13:01 +0200 Subject: [PATCH] New Logs Panel: Display nanoseconds if present (#109210) * processing: display nanoseconds if present * Nanoseconds: add control in log line menu * virtualization: adapt to timestamp format * Translations * LogLine: update test * virtualization: update test * LogLineMenu: update test * LogListControls: add third state for new logs panel timestamps * Logs Panel: expose as a new panel option * Translations * Logs Panel: set timestamp resolution from panel config * Module: add default value * LogLine: update test * Spelling * chore: rename setter * LogListControls: use custom button for resolution * Translations * Translations * Prettier * Logs Panel: conditionally show timestamp and details options * Add integration test --- .../logs/panelcfg/x/LogsPanelCfg_types.gen.ts | 1 + .../logs/components/panel/LogLine.test.tsx | 32 ++++ .../logs/components/panel/LogLine.tsx | 13 +- .../logs/components/panel/LogLineMenu.tsx | 24 +-- .../logs/components/panel/LogList.test.tsx | 29 +++- .../logs/components/panel/LogList.tsx | 13 +- .../logs/components/panel/LogListContext.tsx | 142 ++++++++++++------ .../logs/components/panel/LogListControls.tsx | 108 +++++++++++-- .../panel/__mocks__/LogListContext.tsx | 7 + .../logs/components/panel/processing.ts | 28 +++- .../components/panel/virtualization.test.ts | 15 +- .../logs/components/panel/virtualization.ts | 9 +- public/app/plugins/panel/logs/LogsPanel.tsx | 5 + public/app/plugins/panel/logs/module.tsx | 84 +++++++---- public/app/plugins/panel/logs/panelcfg.cue | 1 + public/app/plugins/panel/logs/panelcfg.gen.ts | 1 + public/locales/en-US/grafana.json | 13 +- 17 files changed, 412 insertions(+), 113 deletions(-) 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 da04269f0b3..5a048b59af0 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 @@ -45,6 +45,7 @@ export interface Options { showTime: boolean; sortOrder: common.LogsSortOrder; syntaxHighlighting?: boolean; + timestampResolution?: ('ms' | 'ns'); wrapLogMessage: boolean; } diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index 89803a457bb..de1d119afae 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -79,6 +79,38 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => { expect(screen.getByText('log message 1')).toBeInTheDocument(); }); + test('Renders a log line with millisecond timestamps', () => { + log.timestamp = '2025-08-06 11:35:19.504'; + render( + + + + ); + expect(screen.getByText('2025-08-06 11:35:19.504')).toBeInTheDocument(); + }); + + test('Renders a log line with nanosecond timestamps', () => { + log.timestamp = '2025-08-06 11:35:19.504'; + log.timeEpochMs = 1754472919504; + log.timeEpochNs = '1754472919504133766'; + render( + + + + ); + expect(screen.getByText('2025-08-06 11:35:19.504133766')).toBeInTheDocument(); + }); + test('Renders a log line with displayed fields', () => { render( diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index f3b994250f5..72934c8cffd 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -99,6 +99,7 @@ const LogLineComponent = memo( fontSize, hasLogsWithErrors, hasSampledLogs, + timestampResolution, onLogLineHover, } = useLogListContext(); const [collapsed, setCollapsed] = useState( @@ -211,6 +212,7 @@ const LogLineComponent = memo( log={log} showTime={showTime} styles={styles} + timestampResolution={timestampResolution} wrapLogMessage={wrapLogMessage} /> @@ -248,18 +250,25 @@ const LogLineComponent = memo( ); LogLineComponent.displayName = 'LogLineComponent'; +export type LogLineTimestampResolution = 'ms' | 'ns'; + interface LogProps { displayedFields: string[]; log: LogListModel; showTime: boolean; styles: LogLineStyles; + timestampResolution: LogLineTimestampResolution; wrapLogMessage: boolean; } -const Log = memo(({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProps) => { +const Log = memo(({ displayedFields, log, showTime, styles, timestampResolution, wrapLogMessage }: LogProps) => { return ( <> - {showTime && {log.timestamp}} + {showTime && ( + + {timestampResolution === 'ms' ? log.timestamp : log.timestampNs} + + )} { // When logs are unwrapped, we want an empty column space to align with other log lines. } diff --git a/public/app/features/logs/components/panel/LogLineMenu.tsx b/public/app/features/logs/components/panel/LogLineMenu.tsx index e2ab764e3d9..a9bba004538 100644 --- a/public/app/features/logs/components/panel/LogLineMenu.tsx +++ b/public/app/features/logs/components/panel/LogLineMenu.tsx @@ -134,23 +134,23 @@ export const LogLineMenu = ({ log, styles }: Props) => { ), [ - enableLogDetails, - toggleLogDetails, + copyLinkToLogLine, + copyLogLine, detailsDisplayed, + enableLogDetails, + isAssistantAvailable, log, + logLineMenuCustomItems, + onPermalinkClick, + onPinLine, + onUnpinLine, + openAssistantByLog, + pinned, shouldlogSupportsContext, showContext, - pinned, - onPinLine, - togglePinning, - onUnpinLine, showFirstDivider, - copyLogLine, - onPermalinkClick, - copyLinkToLogLine, - logLineMenuCustomItems, - isAssistantAvailable, - openAssistantByLog, + toggleLogDetails, + togglePinning, ] ); diff --git a/public/app/features/logs/components/panel/LogList.test.tsx b/public/app/features/logs/components/panel/LogList.test.tsx index 4f740d8059b..4032aa2b9e4 100644 --- a/public/app/features/logs/components/panel/LogList.test.tsx +++ b/public/app/features/logs/components/panel/LogList.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { CoreApp, getDefaultTimeRange, LogRowModel, LogsDedupStrategy, LogsSortOrder, store } from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils'; import { createLogRow } from '../mocks/logRow'; @@ -36,6 +36,14 @@ jest.mock('../../utils', () => ({ enablePopoverMenu: jest.fn(), })); +const originalFlagValue = config.featureToggles.newLogsPanel; +beforeAll(() => { + config.featureToggles.newLogsPanel = true; +}); +afterAll(() => { + config.featureToggles.newLogsPanel = originalFlagValue; +}); + describe('LogList', () => { let logs: LogRowModel[], defaultProps: Props; beforeEach(() => { @@ -344,6 +352,25 @@ describe('LogList', () => { expect(screen.getByText('log message 1')).toBeInTheDocument(); expect(screen.getByText('some text')).toBeInTheDocument(); }); + + test('Allows to toggle between ms and ns precision timestamps', async () => { + logs = [createLogRow({ uid: '1', timeEpochMs: 1754472919504, timeEpochNs: '1754472919504133766' })]; + + render(); + + expect(screen.getByText('2025-08-06 03:35:19.504')).toBeInTheDocument(); + expect(screen.getByLabelText('Show nanosecond timestamps')).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('Show nanosecond timestamps')); + + expect(screen.getByText('2025-08-06 03:35:19.504133766')).toBeInTheDocument(); + expect(screen.getByLabelText('Hide timestamps')).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('Hide timestamps')); + + expect(screen.queryByText(/2025-08-06 03:35:19/)).not.toBeInTheDocument(); + expect(screen.getByLabelText('Show millisecond timestamps')).toBeInTheDocument(); + }); }); describe('Interactions', () => { beforeEach(() => { diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index b9c55ab1b80..f3388cdf82f 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -24,7 +24,7 @@ import { PopoverMenu } from 'app/features/explore/Logs/PopoverMenu'; import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; import { InfiniteScrollMode, InfiniteScroll, LoadMoreLogsType } from './InfiniteScroll'; -import { getGridTemplateColumns } from './LogLine'; +import { getGridTemplateColumns, LogLineTimestampResolution } from './LogLine'; import { LogLineDetails, LogLineDetailsMode } from './LogLineDetails'; import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext'; @@ -80,6 +80,7 @@ export interface Props { showTime: boolean; sortOrder: LogsSortOrder; timeRange: TimeRange; + timestampResolution?: LogLineTimestampResolution; timeZone: string; syntaxHighlighting?: boolean; wrapLogMessage: boolean; @@ -148,6 +149,7 @@ export const LogList = ({ sortOrder, syntaxHighlighting = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.syntaxHighlighting`, true) : true, timeRange, + timestampResolution, timeZone, wrapLogMessage, }: Props) => { @@ -189,6 +191,7 @@ export const LogList = ({ showTime={showTime} sortOrder={sortOrder} syntaxHighlighting={syntaxHighlighting} + timestampResolution={timestampResolution} wrapLogMessage={wrapLogMessage} > @@ -241,6 +244,7 @@ const LogListComponent = ({ showDetails, showTime, sortOrder, + timestampResolution, toggleDetails, wrapLogMessage, } = useLogListContext(); @@ -253,8 +257,11 @@ const LogListComponent = ({ const scrollRef = useRef(null); const virtualization = useMemo(() => new LogLineVirtualization(theme, fontSize), [theme, fontSize]); const dimensions = useMemo( - () => (wrapLogMessage ? [] : virtualization.calculateFieldDimensions(processedLogs, displayedFields)), - [displayedFields, processedLogs, virtualization, wrapLogMessage] + () => + wrapLogMessage + ? [] + : virtualization.calculateFieldDimensions(processedLogs, displayedFields, timestampResolution), + [displayedFields, processedLogs, timestampResolution, virtualization, wrapLogMessage] ); const styles = useStyles2(getStyles, dimensions, displayedFields, { showTime }); const widthContainer = wrapperRef.current ?? containerElement; diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index b50df85aa84..c302a510761 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -11,7 +11,12 @@ import { useState, } from 'react'; -import { createContext as createAssistantContext, ItemDataType, useAssistant } from '@grafana/assistant'; +import { + createContext as createAssistantContext, + ItemDataType, + OpenAssistantProps, + useAssistant, +} from '@grafana/assistant'; import { CoreApp, DataFrame, @@ -30,6 +35,7 @@ import { PopoverContent } from '@grafana/ui'; import { checkLogsError, checkLogsSampled, downloadLogs as download, DownloadFormat } from '../../utils'; import { getDisplayedFieldsForLogs } from '../otel/formats'; +import { LogLineTimestampResolution } from './LogLine'; import { LogLineDetailsMode } from './LogLineDetails'; import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu'; import { LogListFontSize } from './LogList'; @@ -62,8 +68,10 @@ export interface LogListContextData extends Omit void; setShowUniqueLabels: (showUniqueLabels: boolean) => void; setSortOrder: (sortOrder: LogsSortOrder) => void; + setTimestampResolution: (format: LogLineTimestampResolution) => void; setWrapLogMessage: (showTime: boolean) => void; showDetails: LogListModel[]; + timestampResolution: LogLineTimestampResolution; toggleDetails: (log: LogListModel) => void; isAssistantAvailable: boolean; openAssistantByLog: ((log: LogListModel) => void) | undefined; @@ -97,11 +105,13 @@ export const LogListContext = createContext({ setShowUniqueLabels: () => {}, setSortOrder: () => {}, setSyntaxHighlighting: () => {}, + setTimestampResolution: () => {}, setWrapLogMessage: () => {}, showDetails: [], showTime: true, sortOrder: LogsSortOrder.Ascending, syntaxHighlighting: true, + timestampResolution: 'ns', toggleDetails: () => {}, wrapLogMessage: false, isAssistantAvailable: false, @@ -139,6 +149,7 @@ export type LogListState = Pick< | 'showTime' | 'sortOrder' | 'syntaxHighlighting' + | 'timestampResolution' | 'wrapLogMessage' >; @@ -183,6 +194,7 @@ export interface Props { showTime: boolean; sortOrder: LogsSortOrder; syntaxHighlighting?: boolean; + timestampResolution?: LogLineTimestampResolution; wrapLogMessage: boolean; } @@ -226,6 +238,9 @@ export const LogListContextProvider = ({ showUniqueLabels, sortOrder, syntaxHighlighting, + timestampResolution = logOptionsStorageKey + ? (store.get(`${logOptionsStorageKey}.timestampResolution`) ?? 'ms') + : 'ms', wrapLogMessage, }: Props) => { const [logListState, setLogListState] = useState({ @@ -240,6 +255,7 @@ export const LogListContextProvider = ({ showUniqueLabels, sortOrder, syntaxHighlighting, + timestampResolution, wrapLogMessage, }); const [showDetails, setShowDetails] = useState([]); @@ -247,47 +263,6 @@ export const LogListContextProvider = ({ const [detailsMode, setDetailsMode] = useState(detailsModeProp ?? 'sidebar'); const [isAssistantAvailable, openAssistant] = useAssistant(); - const openAssistantByLog = useCallback( - async (log: LogListModel) => { - if (!openAssistant) { - return; - } - - const datasource = await getDataSourceSrv().get(log.datasourceUid); - const context = []; - if (datasource) { - context.push( - createAssistantContext(ItemDataType.Datasource, { - datasourceUid: datasource.uid, - datasourceName: datasource.name, - datasourceType: datasource.type, - img: datasource.meta?.info?.logos?.small, - }) - ); - } - openAssistant({ - prompt: `${t('logs.log-line-menu.log-line-explainer', 'Explain this log line in a concise way')}: - - \`\`\` -${log.entry.replaceAll('`', '\\`')} - \`\`\` - `, - context: [ - ...context, - createAssistantContext(ItemDataType.Structured, { - title: t('logs.log-line-menu.log-line', 'Log line'), - data: { - labels: log.labels, - value: log.entry, - timestamp: log.timestamp, - }, - }), - ], - }); - }, - [openAssistant] - ); - useEffect(() => { if (noInteractions) { return; @@ -302,11 +277,13 @@ ${log.entry.replaceAll('`', '\\`')} detailsWidth, detailsMode, withDisplayedFields: displayedFields.length > 0, + timestampResolution: logListState.timestampResolution, }); // Just once // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // OTel displayed fields useEffect(() => { if (displayedFields.length > 0 || !config.featureToggles.otelLogsFormatting || !setDisplayedFields) { return; @@ -317,6 +294,7 @@ ${log.entry.replaceAll('`', '\\`')} } }, [displayedFields.length, logs, setDisplayedFields]); + // Sync state useEffect(() => { // Props are updated in the context only of the panel is being externally controlled. if (showControls && app !== CoreApp.PanelEditor) { @@ -345,6 +323,7 @@ ${log.entry.replaceAll('`', '\\`')} wrapLogMessage, ]); + // Sync filter levels useEffect(() => { if (filterLevels === undefined) { return; @@ -357,16 +336,19 @@ ${log.entry.replaceAll('`', '\\`')} }); }, [filterLevels]); + // Sync font size useEffect(() => { setLogListState((logListState) => ({ ...logListState, fontSize })); }, [fontSize]); + // Sync pinned logs useEffect(() => { if (!shallowCompare(logListState.pinnedLogs ?? [], pinnedLogs ?? [])) { setLogListState({ ...logListState, pinnedLogs }); } }, [logListState, pinnedLogs]); + // Sync show details useEffect(() => { if (!showDetails.length) { return; @@ -379,6 +361,7 @@ ${log.entry.replaceAll('`', '\\`')} } }, [logs, showDetails]); + // Sync log details width useEffect(() => { const handleResize = debounce(() => { setDetailsWidthState((detailsWidth) => getDetailsWidth(containerElement, logOptionsStorageKey, detailsWidth)); @@ -390,6 +373,14 @@ ${log.entry.replaceAll('`', '\\`')} }; }, [containerElement, logOptionsStorageKey]); + // Sync timestamp resolution + useEffect(() => { + setLogListState((state) => ({ + ...state, + timestampResolution, + })); + }, [timestampResolution]); + const detailsDisplayed = useCallback( (log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid), [showDetails] @@ -438,10 +429,16 @@ ${log.entry.replaceAll('`', '\\`')} const setShowTime = useCallback( (showTime: boolean) => { - setLogListState({ ...logListState, showTime }); + const newTimestampFormat = showTime === false ? 'ms' : logListState.timestampResolution; + setLogListState({ + ...logListState, + showTime, + timestampResolution: newTimestampFormat, + }); onLogOptionsChange?.('showTime', showTime); if (logOptionsStorageKey) { store.set(`${logOptionsStorageKey}.showTime`, showTime); + store.set(`${logOptionsStorageKey}.timestampResolution`, newTimestampFormat); } }, [logListState, logOptionsStorageKey, onLogOptionsChange] @@ -552,6 +549,29 @@ ${log.entry.replaceAll('`', '\\`')} [containerElement, logOptionsStorageKey] ); + const setTimestampResolution = useCallback( + (timestampResolution: LogLineTimestampResolution) => { + if (logOptionsStorageKey) { + store.set(`${logOptionsStorageKey}.timestampResolution`, timestampResolution); + } + setLogListState((state) => ({ + ...state, + timestampResolution, + })); + }, + [logOptionsStorageKey] + ); + + const openAssistantByLog = useCallback( + (log: LogListModel) => { + if (!openAssistant) { + return; + } + handleOpenAssistant(openAssistant, log); + }, + [openAssistant] + ); + const hasLogsWithErrors = useMemo(() => logs.some((log) => !!checkLogsError(log)), [logs]); const hasSampledLogs = useMemo(() => logs.some((log) => !!checkLogsSampled(log)), [logs]); const hasUnescapedContent = useMemo(() => logs.some((r) => r.hasUnescapedContent), [logs]); @@ -609,12 +629,14 @@ ${log.entry.replaceAll('`', '\\`')} setShowUniqueLabels, setSortOrder, setSyntaxHighlighting, + setTimestampResolution, setWrapLogMessage, showDetails, showTime: logListState.showTime, showUniqueLabels: logListState.showUniqueLabels, sortOrder: logListState.sortOrder, syntaxHighlighting: logListState.syntaxHighlighting, + timestampResolution: logListState.timestampResolution, toggleDetails, wrapLogMessage: logListState.wrapLogMessage, isAssistantAvailable, @@ -686,3 +708,37 @@ const reportInteractionOnce = (interactionName: string, properties?: Record void, log: LogListModel) { + const datasource = await getDataSourceSrv().get(log.datasourceUid); + const context = []; + if (datasource) { + context.push( + createAssistantContext(ItemDataType.Datasource, { + datasourceUid: datasource.uid, + datasourceName: datasource.name, + datasourceType: datasource.type, + img: datasource.meta?.info?.logos?.small, + }) + ); + } + openAssistant({ + prompt: `${t('logs.log-line-menu.log-line-explainer', 'Explain this log line in a concise way')}: + + \`\`\` +${log.entry.replaceAll('`', '\\`')} + \`\`\` + `, + context: [ + ...context, + createAssistantContext(ItemDataType.Structured, { + title: t('logs.log-line-menu.log-line', 'Log line'), + data: { + labels: log.labels, + value: log.entry, + timestamp: log.timestamp, + }, + }), + ], + }); +} diff --git a/public/app/features/logs/components/panel/LogListControls.tsx b/public/app/features/logs/components/panel/LogListControls.tsx index 670a2767fea..edc1bee1613 100644 --- a/public/app/features/logs/components/panel/LogListControls.tsx +++ b/public/app/features/logs/components/panel/LogListControls.tsx @@ -6,11 +6,12 @@ import { CoreApp, EventBus, LogLevel, LogsDedupDescription, LogsDedupStrategy, L import { GrafanaTheme2 } from '@grafana/data/'; import { t } from '@grafana/i18n'; import { config, reportInteraction } from '@grafana/runtime'; -import { Dropdown, IconButton, Menu, useStyles2 } from '@grafana/ui'; +import { Dropdown, Icon, IconButton, Menu, Tooltip, useStyles2 } from '@grafana/ui'; import { LogsVisualisationType } from '../../../explore/Logs/Logs'; import { DownloadFormat } from '../../utils'; +import { LogLineTimestampResolution } from './LogLine'; import { useLogListContext } from './LogListContext'; import { useLogListSearchContext } from './LogListSearchContext'; import { ScrollToLogsEvent } from './virtualization'; @@ -280,18 +281,22 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) />
- + {config.featureToggles.newLogsPanel ? ( + + ) : ( + + )} {/* When this is used in a Plugin context, app is unknown */} {showUniqueLabels !== undefined && app !== CoreApp.Unknown && ( { + const styles = useStyles2(getStyles); + const { setTimestampResolution, setShowTime, showTime, timestampResolution } = useLogListContext(); + + const onShowTimestampsClick = useCallback(() => { + if (!config.featureToggles.newLogsPanel) { + reportInteraction('logs_log_list_controls_show_time_clicked', { + show_time: !showTime, + }); + setShowTime(!showTime); + return; + } + if (!showTime || timestampResolution === 'ns') { + setShowTime(!showTime); + } else if (timestampResolution === 'ms') { + setTimestampResolution('ns'); + } + }, [setShowTime, setTimestampResolution, showTime, timestampResolution]); + + return ( + + + + ); +}; + const getStyles = (theme: GrafanaTheme2) => { return { navContainer: css({ @@ -510,5 +556,41 @@ const getStyles = (theme: GrafanaTheme2) => { backgroundColor: theme.colors.warning.main, }, }), + timestampResolutionButton: css({ + position: 'relative', + zIndex: 0, + margin: 0, + boxShadow: 'none', + border: 'none', + display: 'inline-flex', + background: 'transparent', + justifyContent: 'center', + alignItems: 'center', + padding: 0, + overflow: 'visible', + }), + timestampResolutionIcon: css({ + verticalAlign: 'baseline', + }), + resolutionText: css({ + color: theme.colors.text.primary, + fontSize: 10, + position: 'absolute', + bottom: -4, + right: 0, + lineHeight: '10px', + backgroundColor: theme.colors.background.elevated, + paddingLeft: 2, + }), }; }; + +function getTimestampTooltip(showTime: boolean, timestampResolution: LogLineTimestampResolution) { + if (!showTime) { + return t('logs.logs-controls.show-ms-timestamps', 'Show millisecond timestamps'); + } + if (timestampResolution === 'ms') { + return t('logs.logs-controls.show-ns-timestamps', 'Show nanosecond timestamps'); + } + return t('logs.logs-controls.hide-timestamps', 'Hide timestamps'); +} diff --git a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx index 53b020bbe88..007ac591dca 100644 --- a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx +++ b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx @@ -39,11 +39,13 @@ export const LogListContext = createContext({ setShowUniqueLabels: () => {}, setSortOrder: () => {}, setSyntaxHighlighting: () => {}, + setTimestampResolution: () => {}, setWrapLogMessage: () => {}, showDetails: [], showTime: true, sortOrder: LogsSortOrder.Ascending, syntaxHighlighting: true, + timestampResolution: 'ns', toggleDetails: () => {}, wrapLogMessage: false, detailsMode: 'sidebar', @@ -87,6 +89,7 @@ export const defaultValue: LogListContextData = { setSortOrder: jest.fn(), setPrettifyJSON: jest.fn(), setSyntaxHighlighting: jest.fn(), + setTimestampResolution: jest.fn(), setWrapLogMessage: jest.fn(), closeDetails: jest.fn(), detailsDisplayed: jest.fn(), @@ -108,6 +111,7 @@ export const defaultValue: LogListContextData = { wrapLogMessage: false, isAssistantAvailable: false, openAssistantByLog: () => {}, + timestampResolution: 'ns', }; export const defaultProps: Props = { @@ -130,6 +134,7 @@ export const defaultProps: Props = { showTime: true, sortOrder: LogsSortOrder.Descending, syntaxHighlighting: true, + timestampResolution: 'ms', wrapLogMessage: true, }; @@ -155,6 +160,7 @@ export const LogListContextProvider = ({ showTime = true, sortOrder = LogsSortOrder.Descending, syntaxHighlighting = true, + timestampResolution = 'ms', wrapLogMessage = true, }: Partial & { showDetails?: LogListModel[] }) => { const hasLogsWithErrors = logs.some((log) => !!checkLogsError(log)); @@ -198,6 +204,7 @@ export const LogListContextProvider = ({ showTime, sortOrder, syntaxHighlighting, + timestampResolution, wrapLogMessage, }} > diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index ee6826aa900..1e01be8d235 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -2,7 +2,16 @@ import ansicolor from 'ansicolor'; import { parse, stringify } from 'lossless-json'; import Prism, { Grammar } from 'prismjs'; -import { DataFrame, dateTimeFormat, Labels, LogLevel, LogRowModel, LogsSortOrder, textUtil } from '@grafana/data'; +import { + DataFrame, + dateTimeFormat, + Labels, + LogLevel, + LogRowModel, + LogsSortOrder, + systemDateFormats, + textUtil, +} from '@grafana/data'; import { config } from '@grafana/runtime'; import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; @@ -90,7 +99,8 @@ export class LogListModel implements LogRowModel { this._grammar = grammar; this.timestamp = dateTimeFormat(log.timeEpochMs, { timeZone, - defaultWithMS: true, + // YYYY-MM-DD HH:mm:ss.SSS + format: systemDateFormats.fullDateMS, }); this._virtualization = virtualization; this._wrapLogMessage = wrapLogMessage; @@ -158,6 +168,11 @@ export class LogListModel implements LogRowModel { return checkLogsSampled(this); } + get timestampNs(): string { + let suffix = this.timeEpochNs.substring(this.timeEpochMs.toString().length); + return this.timestamp + suffix; + } + getDisplayedFieldValue(fieldName: string, stripAnsi = false): string { if (fieldName === LOG_LINE_BODY_FIELD_NAME) { return stripAnsi ? ansicolor.strip(this.body) : this.body; @@ -234,7 +249,14 @@ export const preProcessLogs = ( ): LogListModel[] => { const orderedLogs = sortLogRows(logs, order); return orderedLogs.map((log) => - preProcessLog(log, { escape, getFieldLinks, grammar, timeZone, virtualization, wrapLogMessage }) + preProcessLog(log, { + escape, + getFieldLinks, + grammar, + timeZone, + virtualization, + wrapLogMessage, + }) ); }; diff --git a/public/app/features/logs/components/panel/virtualization.test.ts b/public/app/features/logs/components/panel/virtualization.test.ts index 342262aafd1..8574d22dda6 100644 --- a/public/app/features/logs/components/panel/virtualization.test.ts +++ b/public/app/features/logs/components/panel/virtualization.test.ts @@ -282,7 +282,7 @@ describe('Virtualization', () => { describe('calculateFieldDimensions', () => { test('Measures displayed fields including the log line body', () => { - expect(virtualization.calculateFieldDimensions([log], ['place', LOG_LINE_BODY_FIELD_NAME])).toEqual([ + expect(virtualization.calculateFieldDimensions([log], ['place', LOG_LINE_BODY_FIELD_NAME], 'ms')).toEqual([ { field: 'timestamp', width: 23, @@ -301,6 +301,19 @@ describe('Virtualization', () => { }, ]); }); + + test('Measures nanosecond timestamps', () => { + expect(virtualization.calculateFieldDimensions([log], [], 'ns')).toEqual([ + { + field: 'timestamp', + width: 29, + }, + { + field: 'level', + width: 4, + }, + ]); + }); }); describe('With small font size', () => { diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts index 6207085d1a5..15889c1c2af 100644 --- a/public/app/features/logs/components/panel/virtualization.ts +++ b/public/app/features/logs/components/panel/virtualization.ts @@ -2,6 +2,7 @@ import ansicolor from 'ansicolor'; import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data'; +import { LogLineTimestampResolution } from './LogLine'; import { LOG_LINE_DETAILS_HEIGHT, LogLineDetailsMode } from './LogLineDetails'; import { LogListFontSize } from './LogList'; import { LogListModel } from './processing'; @@ -176,7 +177,11 @@ export class LogLineVirtualization { }; }; - calculateFieldDimensions = (logs: LogListModel[], displayedFields: string[] = []) => { + calculateFieldDimensions = ( + logs: LogListModel[], + displayedFields: string[] = [], + timestampResolution: LogLineTimestampResolution + ) => { if (!logs.length) { return []; } @@ -184,7 +189,7 @@ export class LogLineVirtualization { let levelWidth = 0; const fieldWidths: Record = {}; for (let i = 0; i < logs.length; i++) { - let width = this.measureTextWidth(logs[i].timestamp); + let width = this.measureTextWidth(timestampResolution === 'ms' ? logs[i].timestamp : logs[i].timestampNs); if (width > timestampWidth) { timestampWidth = Math.round(width); } diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 73ee2c3604d..3c80bcacad0 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -125,6 +125,9 @@ interface LogsPanelProps extends PanelProps { * * Set the mode used by the Log Details panel. Displayed as a sidebar, or inline below the log line. Defaults to "inline". * detailsMode?: 'inline' | 'sidebar' + * + * When showing timestamps, toggle between showing nanoseconds or milliseconds. + * timestampResolution?: 'ms' | 'ns' */ } interface LogsPermalinkUrlState { @@ -166,6 +169,7 @@ export const LogsPanel = ({ syntaxHighlighting, detailsMode: detailsModeProp, noInteractions, + timestampResolution, ...options }, id, @@ -607,6 +611,7 @@ export const LogsPanel = ({ logOptionsStorageKey={storageKey} syntaxHighlighting={syntaxHighlighting} timeRange={data.timeRange} + timestampResolution={timestampResolution} timeZone={timeZone} wrapLogMessage={wrapLogMessage} /> diff --git a/public/app/plugins/panel/logs/module.tsx b/public/app/plugins/panel/logs/module.tsx index cccc4cfab01..085df68cdde 100644 --- a/public/app/plugins/panel/logs/module.tsx +++ b/public/app/plugins/panel/logs/module.tsx @@ -7,11 +7,11 @@ import { Options } from './panelcfg.gen'; import { LogsPanelSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(LogsPanel) - .setPanelOptions((builder) => { + .setPanelOptions((builder, context) => { const category = [t('logs.category-logs', 'Logs')]; builder.addBooleanSwitch({ path: 'showTime', - name: t('logs.name-time', 'Time'), + name: t('logs.name-time', 'Show timestamps'), category, description: '', defaultValue: false, @@ -33,6 +33,23 @@ export const plugin = new PanelPlugin(LogsPanel) description: '', defaultValue: false, }); + } else if (context.options?.showTime) { + builder.addRadio({ + path: 'timestampResolution', + name: t('logs.timestamp-format', 'Timestamp resolution'), + category, + description: '', + defaultValue: 'ms', + settings: { + options: [ + { value: 'ms', label: t('logs.logs.timestamp-resolution.label-milliseconds', 'Milliseconds') }, + { + value: 'ns', + label: t('logs.logs.timestamp-resolution.label-nanoseconds', 'Nanoseconds'), + }, + ], + }, + }); } builder.addBooleanSwitch({ @@ -63,24 +80,42 @@ export const plugin = new PanelPlugin(LogsPanel) }); } - builder - .addBooleanSwitch({ - path: 'enableLogDetails', - name: t('logs.name-enable-log-details', 'Enable log details'), + builder.addBooleanSwitch({ + path: 'enableLogDetails', + name: t('logs.name-enable-log-details', 'Enable log details'), + category, + description: '', + defaultValue: true, + }); + + if (config.featureToggles.newLogsPanel && context.options?.enableLogDetails) { + builder.addRadio({ + path: 'detailsMode', + name: t('logs.name-details-mode', 'Log Details panel mode'), category, description: '', - defaultValue: true, - }) - .addBooleanSwitch({ - path: 'enableInfiniteScrolling', - name: t('logs.name-enable-infinite-scrolling', 'Enable infinite scrolling'), - category, - description: t( - 'logs.description-enable-infinite-scrolling', - 'Experimental. Request more results by scrolling to the bottom of the logs list.' - ), - defaultValue: false, + settings: { + options: [ + { value: 'inline', label: t('logs.name-details-options.label-inline', 'Inline') }, + { + value: 'sidebar', + label: t('logs.name-details-options.label-sidebar', 'Sidebar'), + }, + ], + }, }); + } + + builder.addBooleanSwitch({ + path: 'enableInfiniteScrolling', + name: t('logs.name-enable-infinite-scrolling', 'Enable infinite scrolling'), + category, + description: t( + 'logs.description-enable-infinite-scrolling', + 'Experimental. Request more results by scrolling to the bottom of the logs list.' + ), + defaultValue: false, + }); if (config.featureToggles.newLogsPanel) { builder @@ -108,21 +143,6 @@ export const plugin = new PanelPlugin(LogsPanel) }, ], }, - }) - .addRadio({ - path: 'detailsMode', - name: t('logs.name-details-mode', 'Log Details panel mode'), - category, - description: '', - settings: { - options: [ - { value: 'inline', label: t('logs.name-details-options.label-inline', 'Inline') }, - { - value: 'sidebar', - label: t('logs.name-details-options.label-sidebar', 'Sidebar'), - }, - ], - }, }); } diff --git a/public/app/plugins/panel/logs/panelcfg.cue b/public/app/plugins/panel/logs/panelcfg.cue index 1f8f16fe13b..44eb486d3a0 100644 --- a/public/app/plugins/panel/logs/panelcfg.cue +++ b/public/app/plugins/panel/logs/panelcfg.cue @@ -42,6 +42,7 @@ composableKinds: PanelCfg: { noInteractions?: 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") // TODO: figure out how to define callbacks onClickFilterLabel?: _ onClickFilterOutLabel?: _ diff --git a/public/app/plugins/panel/logs/panelcfg.gen.ts b/public/app/plugins/panel/logs/panelcfg.gen.ts index ef4c192dbe2..ad5a194f733 100644 --- a/public/app/plugins/panel/logs/panelcfg.gen.ts +++ b/public/app/plugins/panel/logs/panelcfg.gen.ts @@ -43,6 +43,7 @@ export interface Options { showTime: boolean; sortOrder: common.LogsSortOrder; syntaxHighlighting?: boolean; + timestampResolution?: ('ms' | 'ns'); wrapLogMessage: boolean; } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 5f45a3bab72..4cc86e24c64 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -9543,6 +9543,12 @@ "total-bytes-processed": "Total bytes processed" } }, + "logs": { + "timestamp-resolution": { + "label-milliseconds": "Milliseconds", + "label-nanoseconds": "Nanoseconds" + } + }, "logs-controls": { "deduplication": "Deduplication", "disable-highlighting": "Disable highlighting", @@ -9566,8 +9572,12 @@ "oldest-first": "Sorted by oldest logs first - Click to show newest first", "prettify-json": "Expand JSON logs", "remove-escaping": "Remove escaping", + "resolution-ms": "ms", + "resolution-ns": "ns", "scroll-bottom": "Scroll to bottom", "scroll-top": "Scroll to top", + "show-ms-timestamps": "Show millisecond timestamps", + "show-ns-timestamps": "Show nanosecond timestamps", "show-search": "Search in logs result", "show-timestamps": "Show timestamps", "show-unique-labels": "Show unique labels", @@ -9600,7 +9610,7 @@ "name-order": "Order", "name-prettify-json": "Prettify JSON", "name-show-controls": "Show controls", - "name-time": "Time", + "name-time": "Show timestamps", "name-unique-labels": "Unique labels", "name-wrap-lines": "Wrap lines", "order-options": { @@ -9616,6 +9626,7 @@ "line-contains": "Add as line contains filter", "line-contains-not": "Add as line does not contain filter" }, + "timestamp-format": "Timestamp resolution", "un-themed-log-details": { "aria-label-data-links": "Data links", "aria-label-fields": "Fields",