datatrails: track user interactions (#85909)

* chore: add interaction tracking
This commit is contained in:
Darren Janeczek
2024-04-16 15:55:07 -04:00
committed by GitHub
parent d55ce5b2e8
commit ea90f0119c
15 changed files with 283 additions and 29 deletions

View File

@@ -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<AddToFiltersGraphAc
}
const labelName = Object.keys(labels)[0];
variable.setState({
filters: [
...variable.state.filters,
{
key: labelName,
operator: '=',
value: labels[labelName],
},
],
});
reportExploreMetrics('label_filter_changed', { label: labelName, action: 'added', cause: 'breakdown' });
const trail = getTrailFor(this);
const filter = {
key: labelName,
operator: '=',
value: labels[labelName],
};
trail.addFilterWithoutReportingInteraction(filter);
};
public static Component = ({ model }: SceneComponentProps<AddToFiltersGraphAction>) => {

View File

@@ -29,6 +29,7 @@ import { AutoQueryDef } from '../AutomaticMetricQueries/types';
import { BreakdownLabelSelector } from '../BreakdownLabelSelector';
import { MetricScene } from '../MetricScene';
import { StatusWrapper } from '../StatusWrapper';
import { reportExploreMetrics } from '../interactions';
import { trailDS, VAR_FILTERS, VAR_GROUP_BY, VAR_GROUP_BY_EXP } from '../shared';
import { getColorByIndex, getTrailFor } from '../utils';
@@ -202,6 +203,7 @@ export class BreakdownScene extends SceneObjectBase<BreakdownSceneState> {
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<SelectLabelActionState> {
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<AddToFiltersGraphAction>) => {

View File

@@ -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<SelectableValue<LayoutType>>;
}
export type LayoutType = 'single' | 'grid' | 'rows';
export class LayoutSwitcher extends SceneObjectBase<LayoutSwitcherState> {
private getMetricScene() {
return sceneGraph.getAncestor(this, MetricScene);
@@ -37,8 +38,9 @@ export class LayoutSwitcher extends SceneObjectBase<LayoutSwitcherState> {
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<LayoutSwitcher>) => {

View File

@@ -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<MetricOverviewSceneStat
event.stopPropagation();
sceneGraph.getAncestor(model, MetricScene).setActionView('breakdown');
const groupByVar = sceneGraph.lookupVariable(VAR_GROUP_BY, model);
if (groupByVar instanceof QueryVariable) {
if (groupByVar instanceof QueryVariable && l.label != null) {
reportExploreMetrics('label_selected', { label: l.label, cause: 'overview_link' });
groupByVar.setState({ value: l.value });
}
return false;

View File

@@ -0,0 +1 @@
export type LayoutType = 'single' | 'grid' | 'rows';

View File

@@ -32,6 +32,7 @@ import { MetricSelectScene } from './MetricSelectScene';
import { MetricsHeader } from './MetricsHeader';
import { getTrailStore } from './TrailStore/TrailStore';
import { MetricDatasourceHelper } from './helpers/MetricDatasourceHelper';
import { reportChangeInLabelFilters } from './interactions';
import { MetricSelectedEvent, trailDS, VAR_DATASOURCE, VAR_FILTERS } from './shared';
export interface DataTrailState extends SceneObjectState {
@@ -111,10 +112,22 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
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<DataTrailState> {
},
});
/**
* 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) {

View File

@@ -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<DataTrailSettingsState> {
}
public onToggleStickyMainGraph = () => {
this.setState({ stickyMainGraph: !this.state.stickyMainGraph });
const stickyMainGraph = !this.state.stickyMainGraph;
reportExploreMetrics('settings_changed', { stickyMainGraph });
this.setState({ stickyMainGraph });
};
public onToggleOpen = (isOpen: boolean) => {

View File

@@ -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<DataTrailsHistoryState> {
}
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

View File

@@ -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<DataTrailsHomeState> {
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<DataTrailsHomeState> {
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<DataTrailsHomeState> {
return <Redirect to={getUrlForTrail(trail)} />;
}
const onSelectRecent = (trail: DataTrail) => model.onSelectTrail(trail, false);
const onSelectBookmark = (trail: DataTrail) => model.onSelectTrail(trail, true);
return (
<div className={styles.container}>
<Stack direction={'column'} gap={1} alignItems={'start'}>
@@ -68,7 +73,7 @@ export class DataTrailsHome extends SceneObjectBase<DataTrailsHomeState> {
<DataTrailCard
key={(resolvedTrail.state.key || '') + index}
trail={resolvedTrail}
onSelect={model.onSelectTrail}
onSelect={onSelectRecent}
/>
);
})}
@@ -84,7 +89,7 @@ export class DataTrailsHome extends SceneObjectBase<DataTrailsHomeState> {
<DataTrailCard
key={(resolvedTrail.state.key || '') + index}
trail={resolvedTrail}
onSelect={model.onSelectTrail}
onSelect={onSelectBookmark}
onDelete={() => onDelete(index)}
/>
);

View File

@@ -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));
};
}
}

View File

@@ -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<MetricActionBarState> {
};
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 <a> 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<MetricActionBarState> {
<ToolbarButton
variant={'canvas'}
tooltip="Remove existing metric and choose a new metric"
onClick={() => trail.publishEvent(new MetricSelectedEvent(undefined))}
onClick={() => {
reportExploreMetrics('selected_metric_action_clicked', { action: 'unselect' });
trail.publishEvent(new MetricSelectedEvent(undefined));
}}
>
Select new metric
</ToolbarButton>
@@ -193,7 +198,11 @@ export class MetricActionBar extends SceneObjectBase<MetricActionBarState> {
onClick={toggleBookmark}
/>
{trail.state.embedded && (
<LinkButton href={getUrlForTrail(trail)} variant={'secondary'}>
<LinkButton
href={getUrlForTrail(trail)}
variant={'secondary'}
onClick={() => reportExploreMetrics('selected_metric_action_clicked', { action: 'open_from_embedded' })}
>
Open
</LinkButton>
)}
@@ -207,7 +216,10 @@ export class MetricActionBar extends SceneObjectBase<MetricActionBarState> {
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);
}}
/>
);

View File

@@ -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<MetricSelectSceneState> {
// 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() {

View File

@@ -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');

View File

@@ -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) {

View File

@@ -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<E extends keyof Interactions, P extends Interactions[E]>(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' });
}
}
}
}