New Logs Panel: Add events for Panel and Details (#108272)

* New Logs Panel: Add events for Panel and Details

* Logs Panel: add noInteractions property

* New Logs Panel: conditionally report interactions

* Test
instant
Matias Chomicki 3 days ago committed by GitHub
parent f7a1398cd4
commit 046134db22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts
  2. 27
      public/app/features/logs/components/panel/LogLineDetails.tsx
  3. 11
      public/app/features/logs/components/panel/LogLineDetailsComponent.tsx
  4. 37
      public/app/features/logs/components/panel/LogLineDetailsFields.tsx
  5. 37
      public/app/features/logs/components/panel/LogLineDetailsHeader.tsx
  6. 18
      public/app/features/logs/components/panel/LogList.test.tsx
  7. 3
      public/app/features/logs/components/panel/LogList.tsx
  8. 34
      public/app/features/logs/components/panel/LogListContext.tsx
  9. 23
      public/app/features/logs/components/panel/LogListSearch.tsx
  10. 2
      public/app/plugins/panel/logs/LogsPanel.tsx
  11. 1
      public/app/plugins/panel/logs/panelcfg.cue
  12. 1
      public/app/plugins/panel/logs/panelcfg.gen.ts

@ -24,6 +24,7 @@ export interface Options {
logLineMenuCustomItems?: unknown;
logRowMenuIconsAfter?: unknown;
logRowMenuIconsBefore?: unknown;
noInteractions?: boolean;
/**
* TODO: figure out how to define callbacks
*/

@ -3,6 +3,7 @@ import { Resizable } from 're-resizable';
import { memo, useCallback, useEffect, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { getDragStyles, useStyles2 } from '@grafana/ui';
import { LogLineDetailsComponent } from './LogLineDetailsComponent';
@ -20,13 +21,18 @@ export interface Props {
export type LogLineDetailsMode = 'inline' | 'sidebar';
export const LogLineDetails = ({ containerElement, focusLogLine, logs, onResize }: Props) => {
const { detailsWidth, setDetailsWidth, showDetails } = useLogListContext();
const { detailsWidth, noInteractions, setDetailsWidth, showDetails } = useLogListContext();
const styles = useStyles2(getStyles, 'sidebar');
const dragStyles = useStyles2(getDragStyles);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
focusLogLine(showDetails[0]);
if (!noInteractions) {
reportInteraction('logs_log_line_details_displayed', {
mode: 'sidebar',
});
}
// Just once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -38,6 +44,14 @@ export const LogLineDetails = ({ containerElement, focusLogLine, logs, onResize
onResize();
}, [onResize, setDetailsWidth]);
const reportResize = useCallback(() => {
if (containerRef.current && !noInteractions) {
reportInteraction('logs_log_line_details_sidebar_resized', {
width: Math.round(containerRef.current.clientWidth),
});
}
}, [noInteractions]);
const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH;
if (!showDetails.length) {
@ -47,6 +61,7 @@ export const LogLineDetails = ({ containerElement, focusLogLine, logs, onResize
return (
<Resizable
onResize={handleResize}
onResizeStop={reportResize}
handleClasses={{ left: dragStyles.dragHandleVertical }}
defaultSize={{ width: detailsWidth, height: containerElement.clientHeight }}
size={{ width: detailsWidth, height: containerElement.clientHeight }}
@ -68,10 +83,18 @@ export interface InlineLogLineDetailsProps {
}
export const InlineLogLineDetails = memo(({ logs }: InlineLogLineDetailsProps) => {
const { showDetails } = useLogListContext();
const { noInteractions, showDetails } = useLogListContext();
const styles = useStyles2(getStyles, 'inline');
const scrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!noInteractions) {
reportInteraction('logs_log_line_details_displayed', {
mode: 'inline',
});
}
}, [noInteractions]);
const saveScroll = useCallback(() => {
saveDetailsScrollPosition(showDetails[0], scrollRef.current?.scrollTop ?? 0);
}, [showDetails]);

@ -4,6 +4,7 @@ import { memo, startTransition, useCallback, useMemo, useRef, useState } from 'r
import { DataFrameType, GrafanaTheme2, store } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { ControlledCollapse, useStyles2 } from '@grafana/ui';
import { getLabelTypeFromRow } from '../../utils';
@ -23,7 +24,7 @@ interface LogLineDetailsComponentProps {
}
export const LogLineDetailsComponent = memo(({ log, logs }: LogLineDetailsComponentProps) => {
const { displayedFields, logOptionsStorageKey, setDisplayedFields } = useLogListContext();
const { displayedFields, noInteractions, logOptionsStorageKey, setDisplayedFields } = useLogListContext();
const [search, setSearch] = useState('');
const inputRef = useRef('');
const styles = useStyles2(getStyles);
@ -75,8 +76,14 @@ export const LogLineDetailsComponent = memo(({ log, logs }: LogLineDetailsCompon
const handleToggle = useCallback(
(option: string, isOpen: boolean) => {
store.set(`${logOptionsStorageKey}.log-details.${option}`, isOpen);
if (!noInteractions) {
reportInteraction('logs_log_line_details_section_toggled', {
section: option.replace('Open', ''),
state: isOpen ? 'open' : 'closed',
});
}
},
[logOptionsStorageKey]
[logOptionsStorageKey, noInteractions]
);
const handleSearch = useCallback((newSearch: string) => {

@ -143,6 +143,7 @@ export const LogLineDetailsField = ({
closeDetails,
displayedFields,
isLabelFilterActive,
noInteractions,
onClickFilterLabel,
onClickFilterOutLabel,
onClickShowField,
@ -179,53 +180,59 @@ export const LogLineDetailsField = ({
}
}, [showFieldsStats, updateStats]);
const reportInteractionWrapper = useCallback(
(interactionName: string, properties?: Record<string, unknown>) => {
if (noInteractions) {
return;
}
reportInteraction(interactionName, properties);
},
[noInteractions]
);
const showField = useCallback(() => {
if (onClickShowField) {
onClickShowField(keys[0]);
}
reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', {
reportInteractionWrapper('logs_log_line_details_show_field_clicked', {
datasourceType: log.datasourceType,
logRowUid: log.uid,
type: 'enable',
});
}, [onClickShowField, keys, log.datasourceType, log.uid]);
}, [onClickShowField, reportInteractionWrapper, log.datasourceType, keys]);
const hideField = useCallback(() => {
if (onClickHideField) {
onClickHideField(keys[0]);
}
reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', {
reportInteractionWrapper('logs_log_line_details_hide_field_clicked', {
datasourceType: log.datasourceType,
logRowUid: log.uid,
type: 'disable',
});
}, [onClickHideField, keys, log.datasourceType, log.uid]);
}, [onClickHideField, reportInteractionWrapper, log.datasourceType, keys]);
const filterLabel = useCallback(() => {
if (onClickFilterLabel) {
onClickFilterLabel(keys[0], values[0], logRowToSingleRowDataFrame(log) || undefined);
}
reportInteraction('grafana_explore_logs_log_details_filter_clicked', {
reportInteractionWrapper('logs_log_line_details_filter_clicked', {
datasourceType: log.datasourceType,
filterType: 'include',
logRowUid: log.uid,
});
}, [onClickFilterLabel, keys, values, log]);
}, [onClickFilterLabel, reportInteractionWrapper, log, keys, values]);
const filterOutLabel = useCallback(() => {
if (onClickFilterOutLabel) {
onClickFilterOutLabel(keys[0], values[0], logRowToSingleRowDataFrame(log) || undefined);
}
reportInteraction('grafana_explore_logs_log_details_filter_clicked', {
reportInteractionWrapper('logs_log_line_details_filter_clicked', {
datasourceType: log.datasourceType,
filterType: 'exclude',
logRowUid: log.uid,
});
}, [onClickFilterOutLabel, keys, values, log]);
}, [onClickFilterOutLabel, reportInteractionWrapper, log, keys, values]);
const labelFilterActive = useCallback(async () => {
if (isLabelFilterActive) {
@ -237,14 +244,14 @@ export const LogLineDetailsField = ({
const showStats = useCallback(() => {
setShowFieldStats((showFieldStats: boolean) => !showFieldStats);
reportInteraction('grafana_explore_logs_log_details_stats_clicked', {
reportInteractionWrapper('logs_log_line_details_stats_clicked', {
dataSourceType: log.datasourceType,
fieldType: isLabel ? 'label' : 'detectedField',
fieldType: isLabel ? 'label' : 'field',
type: showFieldsStats ? 'close' : 'open',
logRowUid: log.uid,
app,
});
}, [app, isLabel, log.datasourceType, log.uid, showFieldsStats]);
}, [app, isLabel, log.datasourceType, log.uid, reportInteractionWrapper, showFieldsStats]);
const refIdTooltip = useMemo(
() => (app === CoreApp.Explore && log.dataFrame?.refId ? ` in query ${log.dataFrame?.refId}` : ''),

@ -3,6 +3,7 @@ import { useCallback, useMemo, MouseEvent, useRef, ChangeEvent } from 'react';
import { colorManipulator, GrafanaTheme2, LogRowModel, store } from '@grafana/data';
import { t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { IconButton, Input, useStyles2 } from '@grafana/ui';
import { copyText, handleOpenLogsContextClick } from '../../utils';
@ -26,6 +27,7 @@ export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => {
getRowContextQuery,
logOptionsStorageKey,
logSupportsContext,
noInteractions,
setDetailsMode,
onClickHideField,
onClickShowField,
@ -39,14 +41,27 @@ export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => {
const styles = useStyles2(getStyles, detailsMode, wrapLogMessage);
const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const searchUsedRef = useRef(false);
const reportInteractionWrapper = useCallback(
(interactionName: string, properties?: Record<string, unknown>) => {
if (noInteractions) {
return;
}
reportInteraction(interactionName, properties);
},
[noInteractions]
);
const copyLogLine = useCallback(() => {
copyText(log.entry, containerRef);
}, [log.entry]);
reportInteractionWrapper('logs_log_line_details_header_copy_clicked');
}, [log.entry, reportInteractionWrapper]);
const copyLinkToLogLine = useCallback(() => {
onPermalinkClick?.(log);
}, [log, onPermalinkClick]);
reportInteractionWrapper('logs_log_line_details_header_permalink_clicked');
}, [log, onPermalinkClick, reportInteractionWrapper]);
const togglePinning = useCallback(() => {
if (pinned) {
@ -54,7 +69,8 @@ export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => {
} else {
onPinLine?.(log);
}
}, [log, onPinLine, onUnpinLine, pinned]);
reportInteractionWrapper('logs_log_line_details_header_pinning_clicked');
}, [log, onPinLine, onUnpinLine, pinned, reportInteractionWrapper]);
const shouldlogSupportsContext = useMemo(
() => (logSupportsContext ? logSupportsContext(log) : false),
@ -64,8 +80,9 @@ export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => {
const showContext = useCallback(
async (event: MouseEvent<HTMLElement>) => {
handleOpenLogsContextClick(event, log, getRowContextQuery, (log: LogRowModel) => onOpenContext?.(log, () => {}));
reportInteractionWrapper('logs_log_line_details_header_context_clicked');
},
[onOpenContext, getRowContextQuery, log]
[log, getRowContextQuery, reportInteractionWrapper, onOpenContext]
);
const showLogLineToggle = onClickHideField && onClickShowField && displayedFields.length > 0;
@ -86,7 +103,8 @@ export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => {
} else {
onClickShowField?.(LOG_LINE_BODY_FIELD_NAME);
}
}, [logLineDisplayed, onClickHideField, onClickShowField]);
reportInteractionWrapper('logs_log_line_details_header_show_logline_clicked');
}, [logLineDisplayed, onClickHideField, onClickShowField, reportInteractionWrapper]);
const clearSearch = useMemo(
() => (
@ -95,6 +113,7 @@ export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => {
size="sm"
onClick={() => {
onSearch('');
reportInteractionWrapper('logs_log_line_details_header_search_cleared');
if (inputRef.current) {
inputRef.current.value = '';
}
@ -102,14 +121,18 @@ export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => {
tooltip={t('logs.log-line-details.clear-search', 'Clear')}
/>
),
[onSearch]
[onSearch, reportInteractionWrapper]
);
const handleSearch = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onSearch(e.target.value);
if (!searchUsedRef.current) {
reportInteractionWrapper('logs_log_line_details_header_search_used');
searchUsedRef.current = true;
}
},
[onSearch]
[onSearch, reportInteractionWrapper]
);
return (

@ -2,6 +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 { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils';
import { createLogRow } from '../mocks/logRow';
@ -12,6 +13,7 @@ jest.mock('@grafana/runtime', () => {
return {
...jest.requireActual('@grafana/runtime'),
usePluginLinks: jest.fn().mockReturnValue({ links: [] }),
reportInteraction: jest.fn(),
config: {
...jest.requireActual('@grafana/runtime').config,
featureToggles: {
@ -338,4 +340,20 @@ describe('LogList', () => {
expect(screen.getByText('some text')).toBeInTheDocument();
});
});
describe('Interactions', () => {
beforeEach(() => {
sessionStorage.clear();
jest.mocked(reportInteraction).mockClear();
});
test('Reports interactions ', async () => {
render(<LogList {...defaultProps} />);
await screen.findByText('log message 1');
expect(reportInteraction).toHaveBeenCalled();
});
test('Can disable interaction report ', async () => {
render(<LogList {...defaultProps} noInteractions={true} />);
await screen.findByText('log message 1');
expect(reportInteraction).not.toHaveBeenCalled();
});
});
});

@ -59,6 +59,7 @@ export interface Props {
logs: LogRowModel[];
logsMeta?: LogsMetaItem[];
logSupportsContext?: (row: LogRowModel) => boolean;
noInteractions?: boolean;
onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void;
onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void;
onClickFilterString?: (value: string, refId?: string) => void;
@ -124,6 +125,7 @@ export const LogList = ({
logs,
logsMeta,
logSupportsContext,
noInteractions,
onClickFilterLabel,
onClickFilterOutLabel,
onClickFilterString,
@ -165,6 +167,7 @@ export const LogList = ({
logLineMenuCustomItems={logLineMenuCustomItems}
logOptionsStorageKey={logOptionsStorageKey}
logSupportsContext={logSupportsContext}
noInteractions={noInteractions}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickFilterString={onClickFilterString}

@ -22,7 +22,7 @@ import {
shallowCompare,
store,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { config, reportInteraction } from '@grafana/runtime';
import { PopoverContent } from '@grafana/ui';
import { DownloadFormat, checkLogsError, checkLogsSampled, downloadLogs as download } from '../../utils';
@ -79,6 +79,7 @@ export const LogListContext = createContext<LogListContextData>({
forceEscape: false,
fontSize: 'default',
hasUnescapedContent: false,
noInteractions: false,
setDedupStrategy: () => {},
setDetailsMode: () => {},
setDetailsWidth: () => {},
@ -153,6 +154,7 @@ export interface Props {
logsMeta?: LogsMetaItem[];
logOptionsStorageKey?: string;
logSupportsContext?: (row: LogRowModel) => boolean;
noInteractions?: boolean;
onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void;
onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void;
onClickFilterString?: (value: string, refId?: string) => void;
@ -195,6 +197,7 @@ export const LogListContextProvider = ({
logsMeta,
logOptionsStorageKey,
logSupportsContext,
noInteractions,
onClickFilterLabel,
onClickFilterOutLabel,
onClickFilterString,
@ -237,6 +240,25 @@ export const LogListContextProvider = ({
const [detailsWidth, setDetailsWidthState] = useState(getDetailsWidth(containerElement, logOptionsStorageKey));
const [detailsMode, setDetailsMode] = useState<LogLineDetailsMode>(detailsModeProp ?? 'sidebar');
useEffect(() => {
if (noInteractions) {
return;
}
reportInteractionOnce(`logs_log_list_${app}_logs_displayed`, {
dedupStrategy,
fontSize,
forceEscape: logListState.forceEscape,
showTime,
syntaxHighlighting,
wrapLogMessage,
detailsWidth,
detailsMode,
withDisplayedFields: displayedFields.length > 0,
});
// Just once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (displayedFields.length > 0 || !config.featureToggles.otelLogsFormatting || !setDisplayedFields) {
return;
@ -510,6 +532,7 @@ export const LogListContextProvider = ({
logSupportsContext,
logLineMenuCustomItems,
logOptionsStorageKey,
noInteractions: noInteractions ?? false,
onClickFilterLabel,
onClickFilterOutLabel,
onClickFilterString,
@ -605,3 +628,12 @@ export function getDetailsScrollPosition(log: LogListModel) {
export function removeDetailsScrollPosition(log: LogListModel) {
detailsScrollMap.delete(log.uid);
}
const reportInteractionOnce = (interactionName: string, properties?: Record<string, unknown>) => {
const key = `logs.log-list-context.events.${interactionName}`;
if (sessionStorage.getItem(key)) {
return;
}
sessionStorage.setItem(key, '1');
reportInteraction(interactionName, properties);
};

@ -4,6 +4,7 @@ import { VariableSizeList } from 'react-window';
import { escapeRegex, GrafanaTheme2, shallowCompare } from '@grafana/data';
import { t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { IconButton, Input, useStyles2 } from '@grafana/ui';
import { useLogListContext } from './LogListContext';
@ -26,10 +27,11 @@ export const LogListSearch = ({ listRef, logs }: Props) => {
searchVisible,
toggleFilterLogs,
} = useLogListSearchContext();
const { displayedFields } = useLogListContext();
const { displayedFields, noInteractions } = useLogListContext();
const [search, setSearch] = useState('');
const [currentResult, setCurrentResult] = useState<number | null>(null);
const inputRef = useRef('');
const searchUsedRef = useRef(false);
const styles = useStyles2(getStyles);
const matches = useMemo(() => {
@ -39,12 +41,19 @@ export const LogListSearch = ({ listRef, logs }: Props) => {
return findMatchingLogs(logs, search, displayedFields);
}, [displayedFields, logs, search, searchVisible]);
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
inputRef.current = e.target.value;
startTransition(() => {
setSearch(inputRef.current);
});
}, []);
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
inputRef.current = e.target.value;
startTransition(() => {
setSearch(inputRef.current);
});
if (!searchUsedRef.current && !noInteractions) {
reportInteraction('logs_log_list_search_used');
searchUsedRef.current = true;
}
},
[noInteractions]
);
const prevResult = useCallback(() => {
if (currentResult === null) {

@ -164,6 +164,7 @@ export const LogsPanel = ({
fontSize,
syntaxHighlighting,
detailsMode: detailsModeProp,
noInteractions,
...options
},
id,
@ -566,6 +567,7 @@ export const LogsPanel = ({
logs={deduplicatedRows}
logSupportsContext={showContextToggle}
loadMore={enableInfiniteScrolling ? loadMoreLogs : undefined}
noInteractions={noInteractions}
onClickFilterLabel={
isOnClickFilterLabel(onClickFilterLabel) ? onClickFilterLabel : defaultOnClickFilterLabel
}

@ -39,6 +39,7 @@ composableKinds: PanelCfg: {
sortOrder: common.LogsSortOrder
dedupStrategy: common.LogsDedupStrategy
enableInfiniteScrolling?: bool
noInteractions?: bool
fontSize?: "default" | "small" @cuetsy(kind="enum", memberNames="default|small")
detailsMode?: "inline" | "sidebar" @cuetsy(kind="enum", memberNames="inline|sidebar")
// TODO: figure out how to define callbacks

@ -22,6 +22,7 @@ export interface Options {
logLineMenuCustomItems?: unknown;
logRowMenuIconsAfter?: unknown;
logRowMenuIconsBefore?: unknown;
noInteractions?: boolean;
/**
* TODO: figure out how to define callbacks
*/

Loading…
Cancel
Save