diff --git a/public/app/features/trails/ActionTabs/AddToFiltersGraphAction.tsx b/public/app/features/trails/ActionTabs/AddToFiltersGraphAction.tsx index d3a3d6d59f3..4fbea1d2584 100644 --- a/public/app/features/trails/ActionTabs/AddToFiltersGraphAction.tsx +++ b/public/app/features/trails/ActionTabs/AddToFiltersGraphAction.tsx @@ -10,6 +10,9 @@ import { } from '@grafana/scenes'; import { Button } from '@grafana/ui'; +import { reportExploreMetrics } from '../interactions'; +import { getTrailFor } from '../utils'; + export interface AddToFiltersGraphActionState extends SceneObjectState { frame: DataFrame; } @@ -27,17 +30,14 @@ export class AddToFiltersGraphAction extends SceneObjectBase { return; } + reportExploreMetrics('label_selected', { label: value, cause: 'selector' }); const variable = this.getVariable(); variable.changeValueTo(value); @@ -429,7 +431,9 @@ interface SelectLabelActionState extends SceneObjectState { } export class SelectLabelAction extends SceneObjectBase { public onClick = () => { - getBreakdownSceneFor(this).onChange(this.state.labelName); + const label = this.state.labelName; + reportExploreMetrics('label_selected', { label, cause: 'breakdown_panel' }); + getBreakdownSceneFor(this).onChange(label); }; public static Component = ({ model }: SceneComponentProps) => { diff --git a/public/app/features/trails/ActionTabs/LayoutSwitcher.tsx b/public/app/features/trails/ActionTabs/LayoutSwitcher.tsx index f2f1ae206d4..3bf7bafa9f1 100644 --- a/public/app/features/trails/ActionTabs/LayoutSwitcher.tsx +++ b/public/app/features/trails/ActionTabs/LayoutSwitcher.tsx @@ -5,14 +5,15 @@ import { SceneComponentProps, sceneGraph, SceneObject, SceneObjectBase, SceneObj import { Field, RadioButtonGroup } from '@grafana/ui'; import { MetricScene } from '../MetricScene'; +import { reportExploreMetrics } from '../interactions'; + +import { LayoutType } from './types'; export interface LayoutSwitcherState extends SceneObjectState { layouts: SceneObject[]; options: Array>; } -export type LayoutType = 'single' | 'grid' | 'rows'; - export class LayoutSwitcher extends SceneObjectBase { private getMetricScene() { return sceneGraph.getAncestor(this, MetricScene); @@ -37,8 +38,9 @@ export class LayoutSwitcher extends SceneObjectBase { return activeLayout; } - public onLayoutChange = (active: LayoutType) => { - this.getMetricScene().setState({ layout: active }); + public onLayoutChange = (layout: LayoutType) => { + reportExploreMetrics('breakdown_layout_changed', { layout }); + this.getMetricScene().setState({ layout }); }; public static Component = ({ model }: SceneComponentProps) => { diff --git a/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx b/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx index 8803de661a6..59e9770c2c3 100644 --- a/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx +++ b/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx @@ -14,6 +14,7 @@ import { Stack, Text, TextLink } from '@grafana/ui'; import { ALL_VARIABLE_VALUE } from '../../variables/constants'; import { MetricScene } from '../MetricScene'; import { StatusWrapper } from '../StatusWrapper'; +import { reportExploreMetrics } from '../interactions'; import { VAR_DATASOURCE_EXPR, VAR_GROUP_BY } from '../shared'; import { getMetricSceneFor, getTrailFor } from '../utils'; @@ -102,7 +103,8 @@ export class MetricOverviewScene extends SceneObjectBase { this.goBackToStep(step); }); + const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, this); + const stateSubscription = + filtersVariable instanceof AdHocFiltersVariable && + filtersVariable?.subscribeToState((newState, prevState) => { + if (!this._addingFilterWithoutReportingInteraction) { + reportChangeInLabelFilters(newState.filters, prevState.filters); + } + }); + return () => { if (!this.state.embedded) { getTrailStore().setRecentTrail(this); } + if (stateSubscription) { + stateSubscription?.unsubscribe(); + } }; } @@ -128,6 +141,25 @@ export class DataTrail extends SceneObjectBase { }, }); + /** + * Assuming that the change in filter was already reported with a cause other than `'adhoc_filter'`, + * this will modify the adhoc filter variable and prevent the automatic reporting which would + * normally occur through the call to `reportChangeInLabelFilters`. + */ + public addFilterWithoutReportingInteraction(filter: AdHocVariableFilter) { + const variable = sceneGraph.lookupVariable('filters', this); + if (!(variable instanceof AdHocFiltersVariable)) { + return; + } + + this._addingFilterWithoutReportingInteraction = true; + variable.setState({ + filters: [...variable.state.filters, filter], + }); + this._addingFilterWithoutReportingInteraction = false; + } + private _addingFilterWithoutReportingInteraction = false; + private datasourceHelper = new MetricDatasourceHelper(this); public getMetricMetadata(metric?: string) { diff --git a/public/app/features/trails/DataTrailSettings.tsx b/public/app/features/trails/DataTrailSettings.tsx index 60056bf40d6..513efcc9f1f 100644 --- a/public/app/features/trails/DataTrailSettings.tsx +++ b/public/app/features/trails/DataTrailSettings.tsx @@ -5,6 +5,8 @@ import { GrafanaTheme2 } from '@grafana/data'; import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; import { Dropdown, Switch, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { reportExploreMetrics } from './interactions'; + export interface DataTrailSettingsState extends SceneObjectState { stickyMainGraph?: boolean; isOpen?: boolean; @@ -19,7 +21,9 @@ export class DataTrailSettings extends SceneObjectBase { } public onToggleStickyMainGraph = () => { - this.setState({ stickyMainGraph: !this.state.stickyMainGraph }); + const stickyMainGraph = !this.state.stickyMainGraph; + reportExploreMetrics('settings_changed', { stickyMainGraph }); + this.setState({ stickyMainGraph }); }; public onToggleOpen = (isOpen: boolean) => { diff --git a/public/app/features/trails/DataTrailsHistory.tsx b/public/app/features/trails/DataTrailsHistory.tsx index 33ba46d9915..b69be31ab89 100644 --- a/public/app/features/trails/DataTrailsHistory.tsx +++ b/public/app/features/trails/DataTrailsHistory.tsx @@ -14,6 +14,7 @@ import { import { useStyles2, Tooltip, Stack } from '@grafana/ui'; import { DataTrail, DataTrailState, getTopSceneFor } from './DataTrail'; +import { reportExploreMetrics } from './interactions'; import { VAR_FILTERS } from './shared'; import { getTrailFor, isSceneTimeRangeState } from './utils'; @@ -124,6 +125,10 @@ export class DataTrailHistory extends SceneObjectBase { } this.stepTransitionInProgress = true; + const step = this.state.steps[stepIndex]; + const type = step.type === 'metric' && step.trailState.metric === undefined ? 'metric-clear' : step.type; + reportExploreMetrics('history_step_clicked', { type }); + this.setState({ currentStep: stepIndex }); // The URL will update diff --git a/public/app/features/trails/DataTrailsHome.tsx b/public/app/features/trails/DataTrailsHome.tsx index bb44c43973f..8fe68541118 100644 --- a/public/app/features/trails/DataTrailsHome.tsx +++ b/public/app/features/trails/DataTrailsHome.tsx @@ -11,6 +11,7 @@ import { DataTrail } from './DataTrail'; import { DataTrailCard } from './DataTrailCard'; import { DataTrailsApp } from './DataTrailsApp'; import { getTrailStore } from './TrailStore/TrailStore'; +import { reportExploreMetrics } from './interactions'; import { getDatasourceForNewTrail, getUrlForTrail, newMetricsTrail } from './utils'; export interface DataTrailsHomeState extends SceneObjectState {} @@ -23,14 +24,14 @@ export class DataTrailsHome extends SceneObjectBase { public onNewMetricsTrail = () => { const app = getAppFor(this); const trail = newMetricsTrail(getDatasourceForNewTrail()); - + reportExploreMetrics('exploration_started', { cause: 'new_clicked' }); getTrailStore().setRecentTrail(trail); app.goToUrlForTrail(trail); }; - public onSelectTrail = (trail: DataTrail) => { + public onSelectTrail = (trail: DataTrail, isBookmark: boolean) => { const app = getAppFor(this); - + reportExploreMetrics('exploration_started', { cause: isBookmark ? 'bookmark_clicked' : 'recent_clicked' }); getTrailStore().setRecentTrail(trail); app.goToUrlForTrail(trail); }; @@ -41,6 +42,7 @@ export class DataTrailsHome extends SceneObjectBase { const onDelete = (index: number) => { getTrailStore().removeBookmark(index); + reportExploreMetrics('bookmark_changed', { action: 'deleted' }); setLastDelete(Date.now()); // trigger re-render }; @@ -50,6 +52,9 @@ export class DataTrailsHome extends SceneObjectBase { return ; } + const onSelectRecent = (trail: DataTrail) => model.onSelectTrail(trail, false); + const onSelectBookmark = (trail: DataTrail) => model.onSelectTrail(trail, true); + return (
@@ -68,7 +73,7 @@ export class DataTrailsHome extends SceneObjectBase { ); })} @@ -84,7 +89,7 @@ export class DataTrailsHome extends SceneObjectBase { onDelete(index)} /> ); diff --git a/public/app/features/trails/Integrations/dashboardIntegration.ts b/public/app/features/trails/Integrations/dashboardIntegration.ts index a378758683d..82b2c437b57 100644 --- a/public/app/features/trails/Integrations/dashboardIntegration.ts +++ b/public/app/features/trails/Integrations/dashboardIntegration.ts @@ -8,6 +8,7 @@ import { DataSourceRef } from '@grafana/schema'; import { DashboardModel } from '../../dashboard/state'; import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene'; import { MetricScene } from '../MetricScene'; +import { reportExploreMetrics } from '../interactions'; import { DataTrailEmbedded, DataTrailEmbeddedState } from './DataTrailEmbedded'; import { SceneDrawerAsScene, launchSceneDrawerInGlobalModal } from './SceneDrawer'; @@ -118,9 +119,13 @@ function createClickHandler(item: QueryMetric, dashboard: DashboardScene | Dashb ...commonProps, onDismiss: () => dashboard.closeModal(), }); + reportExploreMetrics('exploration_started', { cause: 'dashboard_panel' }); dashboard.showModal(drawerScene); }; } else { - return () => launchSceneDrawerInGlobalModal(createCommonEmbeddedTrailStateProps(item, dashboard, ds)); + return () => { + reportExploreMetrics('exploration_started', { cause: 'dashboard_panel' }); + launchSceneDrawerInGlobalModal(createCommonEmbeddedTrailStateProps(item, dashboard, ds)); + }; } } diff --git a/public/app/features/trails/MetricScene.tsx b/public/app/features/trails/MetricScene.tsx index b2df2863e86..ac4566f7f94 100644 --- a/public/app/features/trails/MetricScene.tsx +++ b/public/app/features/trails/MetricScene.tsx @@ -17,14 +17,15 @@ import { ToolbarButton, Box, Stack, Icon, TabsBar, Tab, useStyles2, LinkButton, import { getExploreUrl } from '../../core/utils/explore'; import { buildBreakdownActionScene } from './ActionTabs/BreakdownScene'; -import { LayoutType } from './ActionTabs/LayoutSwitcher'; import { buildMetricOverviewScene } from './ActionTabs/MetricOverviewScene'; import { buildRelatedMetricsScene } from './ActionTabs/RelatedMetricsScene'; +import { LayoutType } from './ActionTabs/types'; import { getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine'; import { AutoQueryDef, AutoQueryInfo } from './AutomaticMetricQueries/types'; import { MAIN_PANEL_MAX_HEIGHT, MAIN_PANEL_MIN_HEIGHT, MetricGraphScene } from './MetricGraphScene'; import { ShareTrailButton } from './ShareTrailButton'; import { useBookmarkState } from './TrailStore/useBookmarkState'; +import { reportExploreMetrics } from './interactions'; import { ActionViewDefinition, ActionViewType, @@ -148,6 +149,7 @@ export class MetricActionBar extends SceneObjectBase { }; public openExploreLink = async () => { + reportExploreMetrics('selected_metric_action_clicked', { action: 'open_in_explore' }); this.getLinkToExplore().then((link) => { // We use window.open instead of a Link or because we want to compute the explore link when clicking, // if we precompute it we have to keep track of a lot of dependencies @@ -169,7 +171,10 @@ export class MetricActionBar extends SceneObjectBase { trail.publishEvent(new MetricSelectedEvent(undefined))} + onClick={() => { + reportExploreMetrics('selected_metric_action_clicked', { action: 'unselect' }); + trail.publishEvent(new MetricSelectedEvent(undefined)); + }} > Select new metric @@ -193,7 +198,11 @@ export class MetricActionBar extends SceneObjectBase { onClick={toggleBookmark} /> {trail.state.embedded && ( - + reportExploreMetrics('selected_metric_action_clicked', { action: 'open_from_embedded' })} + > Open )} @@ -207,7 +216,10 @@ export class MetricActionBar extends SceneObjectBase { key={index} label={tab.displayName} active={actionView === tab.value} - onChangeTab={() => metricScene.setActionView(tab.value)} + onChangeTab={() => { + reportExploreMetrics('metric_action_view_changed', { view: tab.value }); + metricScene.setActionView(tab.value); + }} /> ); diff --git a/public/app/features/trails/MetricSelectScene.tsx b/public/app/features/trails/MetricSelectScene.tsx index af2474c2e5a..8fafd0fb107 100644 --- a/public/app/features/trails/MetricSelectScene.tsx +++ b/public/app/features/trails/MetricSelectScene.tsx @@ -27,8 +27,16 @@ import { MetricScene } from './MetricScene'; import { SelectMetricAction } from './SelectMetricAction'; import { StatusWrapper } from './StatusWrapper'; import { getMetricDescription } from './helpers/MetricDatasourceHelper'; +import { reportExploreMetrics } from './interactions'; import { sortRelatedMetrics } from './relatedMetrics'; -import { getVariablesWithMetricConstant, trailDS, VAR_DATASOURCE, VAR_FILTERS_EXPR, VAR_METRIC_NAMES } from './shared'; +import { + getVariablesWithMetricConstant, + MetricSelectedEvent, + trailDS, + VAR_DATASOURCE, + VAR_FILTERS_EXPR, + VAR_METRIC_NAMES, +} from './shared'; import { getFilters, getTrailFor } from './utils'; interface MetricPanel { @@ -96,6 +104,27 @@ export class MetricSelectScene extends SceneObjectBase { // Temp hack when going back to select metric scene and variable updates this.ignoreNextUpdate = true; } + + const trail = getTrailFor(this); + + const metricChangeSubscription = trail.subscribeToEvent(MetricSelectedEvent, (event) => { + const { steps, currentStep } = trail.state.history.state; + const prevStep = steps[currentStep].parentIndex; + const previousMetric = steps[prevStep].trailState.metric; + const isRelatedMetricSelector = previousMetric !== undefined; + + const terms = this.state.searchQuery?.split(splitSeparator).filter((part) => part.length > 0); + if (event.payload !== undefined) { + reportExploreMetrics('metric_selected', { + from: isRelatedMetricSelector ? 'related_metrics' : 'metric_list', + searchTermCount: terms?.length || 0, + }); + } + }); + + return () => { + metricChangeSubscription.unsubscribe(); + }; } private sortedPreviewMetrics() { diff --git a/public/app/features/trails/ShareTrailButton.tsx b/public/app/features/trails/ShareTrailButton.tsx index 35d00639430..3ce02094f45 100644 --- a/public/app/features/trails/ShareTrailButton.tsx +++ b/public/app/features/trails/ShareTrailButton.tsx @@ -4,6 +4,7 @@ import { useLocation } from 'react-use'; import { ToolbarButton } from '@grafana/ui'; import { DataTrail } from './DataTrail'; +import { reportExploreMetrics } from './interactions'; import { getUrlForTrail } from './utils'; interface ShareTrailButtonState { @@ -17,6 +18,7 @@ export const ShareTrailButton = ({ trail }: ShareTrailButtonState) => { const onShare = () => { if (navigator.clipboard) { navigator.clipboard.writeText(origin + getUrlForTrail(trail)); + reportExploreMetrics('selected_metric_action_clicked', { action: 'share_url' }); setTooltip('Copied!'); setTimeout(() => { setTooltip('Copy url'); diff --git a/public/app/features/trails/TrailStore/useBookmarkState.ts b/public/app/features/trails/TrailStore/useBookmarkState.ts index 18336b01489..632d26e0695 100644 --- a/public/app/features/trails/TrailStore/useBookmarkState.ts +++ b/public/app/features/trails/TrailStore/useBookmarkState.ts @@ -1,6 +1,7 @@ import { useState } from 'react'; import { DataTrail } from '../DataTrail'; +import { reportExploreMetrics } from '../interactions'; import { getTrailStore } from './TrailStore'; @@ -21,6 +22,7 @@ export function useBookmarkState(trail: DataTrail) { const isBookmarked = bookmarkIndex != null; const toggleBookmark = () => { + reportExploreMetrics('bookmark_changed', { action: isBookmarked ? 'toggled_off' : 'toggled_on' }); if (isBookmarked) { let indexToRemove = getBookmarkIndex(); while (indexToRemove != null) { diff --git a/public/app/features/trails/interactions.ts b/public/app/features/trails/interactions.ts new file mode 100644 index 00000000000..9e42b731380 --- /dev/null +++ b/public/app/features/trails/interactions.ts @@ -0,0 +1,149 @@ +import { AdHocVariableFilter } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; + +import { LayoutType } from './ActionTabs/types'; +import { TrailStepType } from './DataTrailsHistory'; +import { ActionViewType } from './shared'; + +// prettier-ignore +type Interactions = { + // User selected a label to view its breakdown. + label_selected: { + label: string; + cause: ( + // By clicking the "select" button on that label's breakdown panel + | 'breakdown_panel' + // By clicking the label link on the overview + | 'overview_link' + // By clicking on the label selector at the top of the breakdown + | 'selector' + ); + }; + // User changed a label filter. + label_filter_changed: { + label: string; + action: 'added' | 'removed' | 'changed'; + cause: 'breakdown' | 'adhoc_filter'; + }; + // User changed the breakdown layout + breakdown_layout_changed: { layout: LayoutType }; + // A metric exploration has started due to one of the following causes + exploration_started: { + cause: ( + // a bookmark was clicked from the home page + | 'bookmark_clicked' + // a recent exploration was clicked from the home page + | 'recent_clicked' + // "new exploration" was clicked from the home page + | 'new_clicked' + // the page was loaded (or reloaded) from a URL which matches one of the recent explorations + | 'loaded_local_recent_url' + // the page was loaded from a URL which did not match one of the recent explorations, and is assumed shared + | 'loaded_shared_url' + // the exploration was opened from the dashboard panel menu and is embedded in a drawer + | 'dashboard_panel' + ); + }; + // A user has changed a bookmark + bookmark_changed: { + action: ( + // Toggled on or off from the bookmark icon + | 'toggled_on' + | 'toggled_off' + // Deleted from the homepage bookmarks list + | 'deleted' + ); + }; + // User changes metric explore settings + settings_changed: { stickyMainGraph?: boolean }; + // User clicks on history nodes to navigate exploration history + history_step_clicked: { + type: ( + // One of the the standard step types + | TrailStepType + // The special metric step type that is created when the user de-selects the current metric + | 'metric-clear' + ); + }; + // User clicks on tab to change the action view + metric_action_view_changed: { view: ActionViewType }; + // User clicks on one of the action buttons associated with a selected metric + selected_metric_action_clicked: { + action: ( + // Opens the metric queries in Explore + | 'open_in_explore' + // Clicks on the share URL button + | 'share_url' + // Deselects the current selected metrics by clicking the "Select new metric" button + | 'unselect' + // When in embedded mode, clicked to open the exploration from the embedded view + | 'open_from_embedded' + ); + }; + // User selects a metric + metric_selected: { + from: ( + // By clicking "Select" on a metric panel when on the no-metric-selected metrics list view + | 'metric_list' + // By clicking "Select" on a metric panel when on the related metrics tab + | 'related_metrics' + ); + // The number of search terms activated when the selection was made + searchTermCount: number | null; + }; +}; + +const PREFIX = 'grafana_explore_metrics_'; + +export function reportExploreMetrics(event: E, payload: P) { + reportInteraction(`${PREFIX}${event}`, payload); +} + +/** Detect the single change in filters and report the event, assuming it came from manipulating the adhoc filter */ +export function reportChangeInLabelFilters(newFilters: AdHocVariableFilter[], oldFilters: AdHocVariableFilter[]) { + if (newFilters.length === oldFilters.length) { + for (const oldFilter of oldFilters) { + for (const newFilter of newFilters) { + if (oldFilter.key === newFilter.key) { + if (oldFilter.value !== newFilter.value) { + reportExploreMetrics('label_filter_changed', { + label: oldFilter.key, + action: 'changed', + cause: 'adhoc_filter', + }); + } + } + } + } + } else if (newFilters.length < oldFilters.length) { + for (const oldFilter of oldFilters) { + let foundOldLabel = false; + for (const newFilter of newFilters) { + if (oldFilter.key === newFilter.key) { + foundOldLabel = true; + break; + } + } + if (!foundOldLabel) { + reportExploreMetrics('label_filter_changed', { + label: oldFilter.key, + action: 'removed', + cause: 'adhoc_filter', + }); + } + } + } else { + for (const newFilter of newFilters) { + let foundNewLabel = false; + for (const oldFilter of oldFilters) { + if (oldFilter.key === newFilter.key) { + foundNewLabel = true; + break; + } + } + if (!foundNewLabel) { + reportExploreMetrics('label_filter_changed', { label: newFilter.key, action: 'added', cause: 'adhoc_filter' }); + } + } + } +}