New Logs Context: Add time window option (#109901)

* LogLineDetails: add open and close events

* LogLineContext: add interval picker

* Log Context: allow to customize the time window around the log

* Loki: support custom time window

* LogLineContext: implement variable time window

* Remove console

* LogLineContext: store and format options

* LogLineContext: replace radio with combobox

* LogLineContext: run time window on the first request

* InfiniteScroll: use loading to prevent fake events

* Chore: update old comment

* Minor reorg

* Clean up unnecessary styles

* LogLineContext: fix overflow and scroll

* LogList: fix loading prop

* Update comment

* Translations

* Update public/app/features/logs/components/panel/LogLineContext.tsx

* LogLineContext: move default to constant

* LogLineContext: add test

* Prettier

* New Logs Context: Generic DS support for time window option (#109934)

* chore: add supportsAdjustableWindow to logs context interface

* Build

---------

Co-authored-by: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com>
This commit is contained in:
Matias Chomicki
2025-08-21 13:50:05 +00:00
committed by GitHub
co-authored by Galen Kistler
parent 2b254ed623
commit 01d48e26fe
9 changed files with 222 additions and 89 deletions
+5
View File
@@ -137,6 +137,8 @@ export interface LogRowContextOptions {
direction?: LogRowContextQueryDirection;
limit?: number;
scopedVars?: ScopedVars;
// Optional. Size of the time window to get logs before of after the referenced entry.
timeWindowMs?: number;
}
export enum LogRowContextQueryDirection {
@@ -181,6 +183,9 @@ export interface DataSourceWithLogsContextSupport<TQuery extends DataQuery = Dat
origQuery?: TQuery,
scopedVars?: ScopedVars
): React.ReactNode;
// Does the datasource support the user adjusting the time range in the logs context window? https://github.com/grafana/grafana/pull/109901
supportsAdjustableWindow?: boolean;
}
export const hasLogsContextSupport = (datasource: unknown): datasource is DataSourceWithLogsContextSupport => {
+12 -8
View File
@@ -581,10 +581,12 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
let onCloseContext = useCallback(() => {
setContextOpen(false);
setContextRow(undefined);
reportInteraction('grafana_explore_logs_log_context_closed', {
datasourceType: contextRow?.datasourceType,
logRowUid: contextRow?.uid,
});
if (!config.featureToggles.newLogContext) {
reportInteraction('grafana_explore_logs_log_context_closed', {
datasourceType: contextRow?.datasourceType,
logRowUid: contextRow?.uid,
});
}
onCloseCallbackRef?.current();
}, [contextRow?.datasourceType, contextRow?.uid, onCloseCallbackRef]);
@@ -592,10 +594,12 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
// we are setting the `contextOpen` open state and passing it down to the `LogRow` in order to highlight the row when a LogContext is open
setContextOpen(true);
setContextRow(row);
reportInteraction('grafana_explore_logs_log_context_opened', {
datasourceType: row.datasourceType,
logRowUid: row.uid,
});
if (!config.featureToggles.newLogContext) {
reportInteraction('grafana_explore_logs_log_context_opened', {
datasourceType: row.datasourceType,
logRowUid: row.uid,
});
}
onCloseCallbackRef.current = onClose;
}, []);
@@ -26,6 +26,7 @@ export interface Props {
displayedFields: string[];
handleOverflow: (index: number, id: string, height?: number) => void;
infiniteScrollMode: InfiniteScrollMode;
loading?: boolean;
loadMore?: LoadMoreLogsType;
logs: LogListModel[];
onClick: (e: MouseEvent<HTMLElement>, log: LogListModel) => void;
@@ -50,6 +51,7 @@ export const InfiniteScroll = ({
displayedFields,
handleOverflow,
infiniteScrollMode,
loading,
loadMore,
logs,
onClick,
@@ -102,12 +104,12 @@ export const InfiniteScroll = ({
}, [prevSortOrder, sortOrder]);
useEffect(() => {
if (autoScroll) {
if (autoScroll && !loading) {
setInitialScrollPosition(scrollToLogLineRef.current);
scrollToLogLineRef.current = undefined;
setAutoScroll(false);
}
}, [autoScroll, setInitialScrollPosition]);
}, [autoScroll, loading, setInitialScrollPosition]);
const onLoadMore = useCallback(
(scrollDirection: ScrollDirection) => {
@@ -10,7 +10,7 @@ import {
import { dataFrameToLogsModel } from '../../logsModel';
import { LogLineContext } from './LogLineContext';
import { DEFAULT_TIME_WINDOW, LogLineContext, PAGE_SIZE } from './LogLineContext';
jest.mock('@grafana/assistant', () => ({
...jest.requireActual('@grafana/assistant'),
@@ -542,4 +542,31 @@ describe('LogLineContext', () => {
await waitFor(() => expect(dispatchMock).toHaveBeenCalledWith(splitOpenSym));
});
test('Allows to change the time window surrounding the log', async () => {
row.datasourceType = 'loki';
render(
<LogLineContext
log={row}
open={true}
onClose={() => {}}
getRowContext={getRowContext}
timeZone={timeZone}
sortOrder={LogsSortOrder.Descending}
/>
);
await waitFor(() =>
expect(getRowContext).toHaveBeenCalledWith(expect.anything(), {
limit: PAGE_SIZE,
direction: LogRowContextQueryDirection.Forward,
timeWindowMs: DEFAULT_TIME_WINDOW,
})
);
expect(getRowContext).toHaveBeenCalledWith(expect.anything(), {
limit: PAGE_SIZE,
direction: LogRowContextQueryDirection.Backward,
timeWindowMs: DEFAULT_TIME_WINDOW,
});
});
});
@@ -3,26 +3,30 @@ import { partition } from 'lodash';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AbsoluteTimeRange,
CoreApp,
DataQueryResponse,
DataSourceApi,
DataSourceWithLogsContextSupport,
dateTime,
EventBusSrv,
formattedValueToString,
getValueFormat,
GrafanaTheme2,
hasLogsContextSupport,
LoadingState,
LogRowContextOptions,
LogRowContextQueryDirection,
LogRowModel,
LogsDedupStrategy,
LogsSortOrder,
dateTime,
TimeRange,
LoadingState,
CoreApp,
LogRowModel,
AbsoluteTimeRange,
EventBusSrv,
store,
TimeRange,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { t, Trans } from '@grafana/i18n';
import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime';
import { DataQuery, TimeZone } from '@grafana/schema';
import { Button, Collapse, Modal, useTheme2 } from '@grafana/ui';
import { Button, Collapse, Combobox, ComboboxOption, InlineLabel, Modal, Stack, useTheme2 } from '@grafana/ui';
import { splitOpen } from 'app/features/explore/state/main';
import { useDispatch } from 'app/types/store';
@@ -56,7 +60,8 @@ interface LogLineContextProps {
onClickHideField?: (key: string) => void;
}
const PAGE_SIZE = 100;
export const PAGE_SIZE = 100;
export const DEFAULT_TIME_WINDOW = 7200000;
export const LogLineContext = memo(
({
@@ -84,6 +89,14 @@ export const LogLineContext = memo(
const [aboveState, setAboveState] = useState(LoadingState.NotStarted);
const [belowState, setBelowState] = useState(LoadingState.NotStarted);
const [showLog, setShowLog] = useState(false);
const [datasourceInstance, setDatasourceInstance] = useState<
(DataSourceApi & DataSourceWithLogsContextSupport) | null
>(null);
const defaultTimeWindow = logOptionsStorageKey
? (store.get(`${logOptionsStorageKey}.contextTimeWindow`) ?? DEFAULT_TIME_WINDOW.toString())
: DEFAULT_TIME_WINDOW.toString();
const [timeWindow, setTimeWindow] = useState(parseInt(defaultTimeWindow, 10));
const eventBusRef = useRef(new EventBusSrv());
const dispatch = useDispatch();
@@ -95,8 +108,7 @@ export const LogLineContext = memo(
sortOrder === LogsSortOrder.Ascending ? allLogs[0].timeEpochMs : allLogs[allLogs.length - 1].timeEpochMs;
let toMs =
sortOrder === LogsSortOrder.Ascending ? allLogs[allLogs.length - 1].timeEpochMs : allLogs[0].timeEpochMs;
// In case we have a lot of logs and from and to have same millisecond
// we add 1 millisecond to toMs to make sure we have a range
// Add one millisecond to get a range when from and to are equal.
if (fromMs === toMs) {
toMs += 1;
}
@@ -119,24 +131,23 @@ export const LogLineContext = memo(
setContextQuery(contextQuery);
}, [log, getRowContextQuery]);
const updateResults = useCallback(async () => {
setAboveLogs([]);
setBelowLogs([]);
await updateContextQuery();
setInitialized(false);
}, [updateContextQuery]);
useEffect(() => {
if (open) {
updateContextQuery();
reportInteraction('logs_log_line_context_open', {
datasourceType: log.datasourceType,
uid: log.uid,
});
}
}, [updateContextQuery, open]);
}, [updateContextQuery, open, log]);
const getContextLogs = useCallback(
async (place: 'above' | 'below', refLog: LogRowModel): Promise<LogRowModel[]> => {
async (place: 'above' | 'below', refLog: LogRowModel, timeWindowMs?: number): Promise<LogRowModel[]> => {
const result = await getRowContext(normalizeLogRefId(refLog), {
limit: PAGE_SIZE,
direction: getLoadMoreDirection(place, sortOrder),
// Only on the initial request
timeWindowMs,
});
const newLogs = dataFrameToLogsModel(result.data).rows;
@@ -149,12 +160,12 @@ export const LogLineContext = memo(
);
const loadMore = useCallback(
async (place: 'above' | 'below', refLog: LogRowModel) => {
async (place: 'above' | 'below', refLog: LogRowModel, timeWindow?: number) => {
const setState = place === 'above' ? setAboveState : setBelowState;
setState(LoadingState.Loading);
try {
const newLogs = (await getContextLogs(place, refLog)).map((r) =>
const newLogs = (await getContextLogs(place, refLog, timeWindow)).map((r) =>
// apply the original row's searchWords to all the rows for highlighting
!r.searchWords || !r.searchWords?.length ? { ...r, searchWords: log.searchWords } : r
);
@@ -188,10 +199,10 @@ export const LogLineContext = memo(
return;
}
if (!initialized) {
Promise.all([loadMore('above', log), loadMore('below', log)]).then(() => {});
Promise.all([loadMore('above', log, timeWindow), loadMore('below', log, timeWindow)]);
setInitialized(true);
}
}, [initialized, loadMore, log, open]);
}, [initialized, loadMore, log, open, timeWindow]);
const handleLoadMore = useCallback(
(_: AbsoluteTimeRange, direction: ScrollDirection) => {
@@ -212,10 +223,70 @@ export const LogLineContext = memo(
);
}, [log.uid]);
const onSplitViewClick = useCallback(() => {
if (!contextQuery) {
return;
}
let rowId = log.uid;
if (log.dataFrame.refId) {
// the orignal row has the refid from the base query and not the refid from the context query, so we need to replace it.
rowId = log.uid.replace(log.dataFrame.refId, contextQuery.refId);
}
dispatch(
splitOpen({
queries: [contextQuery],
range: timeRange,
datasourceUid: contextQuery.datasource!.uid!,
panelsState: {
logs: {
id: rowId,
},
},
})
);
onClose();
reportInteraction('logs_log_line_context_open_in_split_clicked', {
datasourceType: log.datasourceType,
});
}, [contextQuery, dispatch, log.dataFrame.refId, log.datasourceType, log.uid, onClose, timeRange]);
const handleTimeWindowChange = useCallback(
(option: ComboboxOption<string>) => {
if (logOptionsStorageKey) {
store.set(`${logOptionsStorageKey}.contextTimeWindow`, option.value);
}
setTimeWindow(parseInt(option.value, 10));
setAboveLogs([]);
setBelowLogs([]);
setInitialized(false);
reportInteraction('logs_log_line_context_time_window_change', {
window_size: option.value,
});
},
[logOptionsStorageKey]
);
const handleClose = useCallback(() => {
reportInteraction('logs_log_line_context_closed', {
datasourceType: log.datasourceType,
uid: log.uid,
});
onClose();
}, [log.datasourceType, log.uid, onClose]);
const updateResults = useCallback(async () => {
setAboveLogs([]);
setBelowLogs([]);
await updateContextQuery();
setInitialized(false);
}, [updateContextQuery]);
const wrapLogMessage = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.wrapLogMessage`, true) : true;
const syntaxHighlighting = logOptionsStorageKey
? store.getBool(`${logOptionsStorageKey}.syntaxHighlighting`, true)
: true;
// @todo: Remove when the LogRows are deprecated
const logListModel = useMemo(
() =>
@@ -229,16 +300,28 @@ export const LogLineContext = memo(
[log, timeZone, wrapLogMessage]
);
useEffect(() => {
if (log.datasourceUid) {
getDataSourceSrv()
.get({ uid: log.datasourceUid })
.then((ds) => {
if (hasLogsContextSupport(ds)) {
setDatasourceInstance(ds);
}
});
}
}, [log.datasourceUid]);
return (
<Modal
isOpen={open}
title={t('logs.log-line-context.title-log-context', 'Log context')}
contentClassName={styles.flexColumn}
className={styles.modal}
onDismiss={onClose}
onDismiss={handleClose}
>
{config.featureToggles.logsContextDatasourceUi && getLogRowContextUi && (
<div className={styles.datasourceUi}>{getLogRowContextUi(log, updateResults)}</div>
<div>{getLogRowContextUi(log, updateResults)}</div>
)}
<Collapse
collapsible={true}
@@ -249,6 +332,38 @@ export const LogLineContext = memo(
>
<LogLineDetailsLog log={logListModel} syntaxHighlighting={syntaxHighlighting} />
</Collapse>
<div className={styles.controls}>
{datasourceInstance?.supportsAdjustableWindow && (
<Stack>
<InlineLabel
htmlFor="time-window-control"
tooltip={t(
'logs.log-line-context.time-window-tooltip',
'Amount of time before and after the referenced log'
)}
width="auto"
>
{t('logs.log-line-context.time-window-label', 'Context time window')}
</InlineLabel>
<Combobox
id="time-window-control"
options={getTimeWindowOptions()}
onChange={handleTimeWindowChange}
value={timeWindow.toString()}
minWidth={5}
width="auto"
/>
</Stack>
)}
<Button variant="secondary" onClick={onScrollCenterClick}>
<Trans i18nKey="logs.log-line-context.center-matched-line">Center matched line</Trans>
</Button>
{contextQuery?.datasource?.uid && (
<Button variant="secondary" onClick={onSplitViewClick}>
<Trans i18nKey="logs.log-line-context.open-in-split-view">Open in split view</Trans>
</Button>
)}
</div>
<div className={styles.loadingIndicator}>
{aboveState === LoadingState.Loading && (
<LoadingIndicator
@@ -306,43 +421,6 @@ export const LogLineContext = memo(
<Trans i18nKey="logs.log-line-context.no-more-logs-available">No more logs available.</Trans>
)}
</div>
<Modal.ButtonRow>
<Button variant="secondary" onClick={onScrollCenterClick}>
<Trans i18nKey="logs.log-line-context.center-matched-line">Center matched line</Trans>
</Button>
{contextQuery?.datasource?.uid && (
<Button
variant="secondary"
onClick={async () => {
let rowId = log.uid;
if (log.dataFrame.refId) {
// the orignal row has the refid from the base query and not the refid from the context query, so we need to replace it.
rowId = log.uid.replace(log.dataFrame.refId, contextQuery.refId);
}
dispatch(
splitOpen({
queries: [contextQuery],
range: timeRange,
datasourceUid: contextQuery.datasource!.uid!,
panelsState: {
logs: {
id: rowId,
},
},
})
);
onClose();
reportInteraction('logs_log_line_context_open_in_split_clicked', {
datasourceType: log.datasourceType,
});
}}
>
<Trans i18nKey="logs.log-line-context.open-in-split-view">Open in split view</Trans>
</Button>
)}
</Modal.ButtonRow>
</Modal>
);
}
@@ -362,10 +440,6 @@ const getStyles = (theme: GrafanaTheme2) => {
left: '50%',
transform: 'translate(-50%, -50%)',
}),
datasourceUi: css({
display: 'flex',
alignItems: 'center',
}),
loadingIndicator: css({
height: theme.spacing(3),
minHeight: theme.spacing(3),
@@ -377,8 +451,8 @@ const getStyles = (theme: GrafanaTheme2) => {
wrapper: css({
border: `1px solid ${theme.colors.border.weak}`,
padding: theme.spacing(0, 1, 1, 0),
flex: 1,
height: '100%',
flex: '1 1 auto',
minHeight: 0,
}),
logsContainer: css({
height: '100%',
@@ -389,6 +463,7 @@ const getStyles = (theme: GrafanaTheme2) => {
flexDirection: 'column',
padding: theme.spacing(0, 3, 3, 3),
height: '100%',
gap: theme.spacing(0.5),
}),
link: css({
color: theme.colors.text.secondary,
@@ -404,6 +479,11 @@ const getStyles = (theme: GrafanaTheme2) => {
width: '75vw',
whiteSpace: 'nowrap',
}),
controls: css({
display: 'flex',
justifyContent: 'flex-end',
gap: theme.spacing(2),
}),
};
};
@@ -445,3 +525,11 @@ const normalizeLogRefId = (log: LogRowModel): LogRowModel => {
const containsRow = (rows: LogRowModel[], row: LogRowModel) => {
return rows.some((r) => r.entry === row.entry && r.timeEpochNs === row.timeEpochNs);
};
function getTimeWindowOptions() {
const intervals = [100, 500, 1000, 5000, 30000, 60000, 300000, 1800000, 3600000, DEFAULT_TIME_WINDOW];
return intervals.map((interval) => ({
label: formattedValueToString(getValueFormat('ms')(interval)),
value: interval.toString(),
}));
}
@@ -96,7 +96,6 @@ type LogListComponentProps = Omit<
| 'dedupStrategy'
| 'displayedFields'
| 'enableLogDetails'
| 'loading'
| 'logOptionsStorageKey'
| 'permalinkedLogId'
| 'showTime'
@@ -203,6 +202,7 @@ export const LogList = ({
grammar={grammar}
initialScrollPosition={initialScrollPosition}
infiniteScrollMode={infiniteScrollMode}
loading={loading}
loadMore={loadMore}
logs={logs}
showControls={showControls}
@@ -221,6 +221,7 @@ const LogListComponent = ({
grammar,
initialScrollPosition = 'top',
infiniteScrollMode = 'interval',
loading,
loadMore,
logs,
showControls,
@@ -452,6 +453,7 @@ const LogListComponent = ({
displayedFields={displayedFields}
handleOverflow={handleOverflow}
infiniteScrollMode={infiniteScrollMode}
loading={loading}
logs={filteredLogs}
loadMore={loadMore}
onClick={handleLogLineClick}
@@ -71,7 +71,7 @@ export class LogContextProvider {
this.cachedContextFilters = filters;
}
return await this.prepareLogRowContextQueryTarget(row, limit, direction, origQuery);
return await this.prepareLogRowContextQueryTarget(row, limit, direction, origQuery, options?.timeWindowMs);
}
getLogRowContextQuery = async (
@@ -136,12 +136,11 @@ export class LogContextProvider {
row: LogRowModel,
limit: number,
direction: LogRowContextQueryDirection,
origQuery?: LokiQuery
origQuery?: LokiQuery,
timeWindowMs = 2 * 60 * 60 * 1000
): Promise<{ query: LokiQuery; range: TimeRange }> {
const expr = this.prepareExpression(this.cachedContextFilters, origQuery);
const contextTimeBuffer = 2 * 60 * 60 * 1000; // 2h buffer
const queryDirection =
direction === LogRowContextQueryDirection.Forward ? LokiQueryDirection.Forward : LokiQueryDirection.Backward;
@@ -174,11 +173,11 @@ export class LogContextProvider {
// because the are before but came it he response that should return only rows after.
from: timestamp,
// convert to ns, we lose some precision here but it is not that important at the far points of the context
to: toUtc(row.timeEpochMs + contextTimeBuffer),
to: toUtc(row.timeEpochMs + timeWindowMs),
}
: {
// convert to ns, we lose some precision here but it is not that important at the far points of the context
from: toUtc(row.timeEpochMs - contextTimeBuffer),
from: toUtc(row.timeEpochMs - timeWindowMs),
to: timestamp,
};
@@ -162,8 +162,12 @@ export class LokiDatasource
};
this.variables = new LokiVariableSupport(this);
this.logContextProvider = new LogContextProvider(this);
this.supportsAdjustableWindow = true;
}
// Flag marking datasource as supporting adjusting the time range window in the logs context window: https://github.com/grafana/grafana/pull/109901
public supportsAdjustableWindow;
/**
* Implemented for DataSourceWithSupplementaryQueriesSupport.
* It generates a DataQueryRequest for a specific supplementary query type.
+2
View File
@@ -9482,6 +9482,8 @@
"no-more-logs-available": "No more logs available.",
"older-logs": "older",
"open-in-split-view": "Open in split view",
"time-window-label": "Context time window",
"time-window-tooltip": "Amount of time before and after the referenced log",
"title-log-context": "Log context",
"title-log-line": "Referenced log line"
},