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 956bff430f7..ec66616abc6 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 @@ -24,6 +24,7 @@ export interface Options { logLineMenuCustomItems?: unknown; logRowMenuIconsAfter?: unknown; logRowMenuIconsBefore?: unknown; + noInteractions?: boolean; /** * TODO: figure out how to define callbacks */ diff --git a/public/app/features/logs/components/panel/LogLineDetails.tsx b/public/app/features/logs/components/panel/LogLineDetails.tsx index fee8143396f..1065ec2456e 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.tsx @@ -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(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 ( { - const { showDetails } = useLogListContext(); + const { noInteractions, showDetails } = useLogListContext(); const styles = useStyles2(getStyles, 'inline'); const scrollRef = useRef(null); + useEffect(() => { + if (!noInteractions) { + reportInteraction('logs_log_line_details_displayed', { + mode: 'inline', + }); + } + }, [noInteractions]); + const saveScroll = useCallback(() => { saveDetailsScrollPosition(showDetails[0], scrollRef.current?.scrollTop ?? 0); }, [showDetails]); diff --git a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx index d38eff282f9..ca964bdebc2 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx @@ -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) => { diff --git a/public/app/features/logs/components/panel/LogLineDetailsFields.tsx b/public/app/features/logs/components/panel/LogLineDetailsFields.tsx index 3f68e7e829a..2fae9493ee5 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsFields.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsFields.tsx @@ -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) => { + 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}` : ''), diff --git a/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx b/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx index 140a7f86fca..014a2c6f8a0 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx @@ -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(null); const inputRef = useRef(null); + const searchUsedRef = useRef(false); + + const reportInteractionWrapper = useCallback( + (interactionName: string, properties?: Record) => { + 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) => { 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) => { onSearch(e.target.value); + if (!searchUsedRef.current) { + reportInteractionWrapper('logs_log_line_details_header_search_used'); + searchUsedRef.current = true; + } }, - [onSearch] + [onSearch, reportInteractionWrapper] ); return ( diff --git a/public/app/features/logs/components/panel/LogList.test.tsx b/public/app/features/logs/components/panel/LogList.test.tsx index fde0ea89dec..3d76e63e413 100644 --- a/public/app/features/logs/components/panel/LogList.test.tsx +++ b/public/app/features/logs/components/panel/LogList.test.tsx @@ -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(); + await screen.findByText('log message 1'); + expect(reportInteraction).toHaveBeenCalled(); + }); + test('Can disable interaction report ', async () => { + render(); + await screen.findByText('log message 1'); + expect(reportInteraction).not.toHaveBeenCalled(); + }); + }); }); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 1934fd420ac..bbb5eca89c9 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -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} diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index 8a2f4c50d46..bf91981ede7 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -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({ 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(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) => { + const key = `logs.log-list-context.events.${interactionName}`; + if (sessionStorage.getItem(key)) { + return; + } + sessionStorage.setItem(key, '1'); + reportInteraction(interactionName, properties); +}; diff --git a/public/app/features/logs/components/panel/LogListSearch.tsx b/public/app/features/logs/components/panel/LogListSearch.tsx index c47dea1449f..3cdd1cbbe3d 100644 --- a/public/app/features/logs/components/panel/LogListSearch.tsx +++ b/public/app/features/logs/components/panel/LogListSearch.tsx @@ -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(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) => { - inputRef.current = e.target.value; - startTransition(() => { - setSearch(inputRef.current); - }); - }, []); + const handleChange = useCallback( + (e: ChangeEvent) => { + 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) { diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 03f2ffbfc35..ca44d725502 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -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 } diff --git a/public/app/plugins/panel/logs/panelcfg.cue b/public/app/plugins/panel/logs/panelcfg.cue index 5f7ac972318..1f8f16fe13b 100644 --- a/public/app/plugins/panel/logs/panelcfg.cue +++ b/public/app/plugins/panel/logs/panelcfg.cue @@ -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 diff --git a/public/app/plugins/panel/logs/panelcfg.gen.ts b/public/app/plugins/panel/logs/panelcfg.gen.ts index 3d28ef5ff55..ef4c192dbe2 100644 --- a/public/app/plugins/panel/logs/panelcfg.gen.ts +++ b/public/app/plugins/panel/logs/panelcfg.gen.ts @@ -22,6 +22,7 @@ export interface Options { logLineMenuCustomItems?: unknown; logRowMenuIconsAfter?: unknown; logRowMenuIconsBefore?: unknown; + noInteractions?: boolean; /** * TODO: figure out how to define callbacks */