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",