diff --git a/public/app/features/explore/utils/links.test.ts b/public/app/features/explore/utils/links.test.ts index ccb73b09626..b161153a2ca 100644 --- a/public/app/features/explore/utils/links.test.ts +++ b/public/app/features/explore/utils/links.test.ts @@ -1,7 +1,9 @@ import { ArrayVector, + CoreApp, DataFrame, DataLink, + DataLinkConfigOrigin, dateTime, Field, FieldType, @@ -10,7 +12,7 @@ import { TimeRange, toDataFrame, } from '@grafana/data'; -import { setTemplateSrv } from '@grafana/runtime'; +import { setTemplateSrv, reportInteraction } from '@grafana/runtime'; import { initTemplateSrv } from '../../../../test/helpers/initTemplateSrv'; import { ContextSrv, setContextSrv } from '../../../core/services/context_srv'; @@ -18,6 +20,11 @@ import { setLinkSrv } from '../../panel/panellinks/link_srv'; import { getFieldLinksForExplore, getVariableUsageInfo } from './links'; +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), +})); + describe('explore links utils', () => { describe('getFieldLinksForExplore', () => { beforeEach(() => { @@ -28,6 +35,12 @@ describe('explore links utils', () => { { type: 'custom', name: 'test', current: { value: 'foo' } }, ]) ); + + jest.spyOn(window, 'open').mockImplementation(); + }); + + afterEach(() => { + jest.resetAllMocks(); }); it('returns correct link model for external link', () => { @@ -44,6 +57,15 @@ describe('explore links utils', () => { expect(links[0].href).toBe('http://regionalhost'); expect(links[0].title).toBe('external'); + expect(links[0].onClick).toBeDefined(); + + links[0].onClick!({}); + + expect(reportInteraction).toBeCalledWith('grafana_data_link_clicked', { + app: CoreApp.Explore, + internal: false, + origin: DataLinkConfigOrigin.Datasource, + }); }); it('returns generates title for external link', () => { @@ -106,6 +128,12 @@ describe('explore links utils', () => { }, }, }); + + expect(reportInteraction).toBeCalledWith('grafana_data_link_clicked', { + app: CoreApp.Explore, + internal: true, + origin: DataLinkConfigOrigin.Datasource, + }); }); it('returns correct link model for external link when user does not have access to explore', () => { @@ -251,6 +279,7 @@ describe('explore links utils', () => { const transformationLink: DataLink = { title: '', url: '', + origin: DataLinkConfigOrigin.Correlations, internal: { query: { query: 'http_requests{app=${application} env=${environment}}' }, datasourceUid: 'uid_1', @@ -281,6 +310,17 @@ describe('explore links utils', () => { '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=foo env=dev}"}]}' )}` ); + + if (links[0][0].onClick) { + links[0][0].onClick({}); + } + + expect(reportInteraction).toBeCalledWith('grafana_data_link_clicked', { + app: CoreApp.Explore, + internal: true, + origin: DataLinkConfigOrigin.Correlations, + }); + expect(links[1]).toHaveLength(1); expect(links[1][0].href).toBe( `/explore?left=${encodeURIComponent( diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index 492a8d33b06..21ed0166985 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -12,8 +12,12 @@ import { SplitOpen, DataLink, DisplayValue, + DataLinkConfigOrigin, + CoreApp, + SplitOpenOptions, } from '@grafana/data'; -import { getTemplateSrv, VariableInterpolation } from '@grafana/runtime'; +import { getTemplateSrv, reportInteraction, VariableInterpolation } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; import { contextSrv } from 'app/core/services/context_srv'; import { getTransformationVars } from 'app/features/correlations/transformations'; @@ -42,6 +46,8 @@ export interface ExploreFieldLinkModel extends LinkModel { variables?: VariableInterpolation[]; } +const DATA_LINK_USAGE_KEY = 'grafana_data_link_clicked'; + /** * Get links from the field of a dataframe and in addition check if there is associated * metadata with datasource in which case we will add onClick to open the link in new split window. This assumes @@ -115,6 +121,30 @@ export const getFieldLinksForExplore = (options: { if (!linkModel.title) { linkModel.title = getTitleFromHref(linkModel.href); } + + // Take over the onClick to report the click, then either call the original onClick or navigate to the URL + // Note: it is likely that an external link that opens in the same tab will not be reported, as the browser redirect might cancel reporting the interaction + const origOnClick = linkModel.onClick; + + linkModel.onClick = (...args) => { + reportInteraction(DATA_LINK_USAGE_KEY, { + origin: link.origin || DataLinkConfigOrigin.Datasource, + app: CoreApp.Explore, + internal: false, + }); + + if (origOnClick) { + origOnClick?.apply(...args); + } else { + // for external links without an onClick, we want to duplicate default href behavior since onClick stops it + if (linkModel.target === '_blank') { + window.open(linkModel.href); + } else { + window.location.href = linkModel.href; + } + } + }; + return linkModel; } else { let internalLinkSpecificVars: ScopedVars = {}; @@ -147,6 +177,16 @@ export const getFieldLinksForExplore = (options: { variables = variableData.variables; } + const splitFnWithTracking = (options?: SplitOpenOptions) => { + reportInteraction(DATA_LINK_USAGE_KEY, { + origin: link.origin || DataLinkConfigOrigin.Datasource, + app: CoreApp.Explore, + internal: true, + }); + + splitOpenFn?.(options); + }; + if (variableData.allVariablesDefined) { const internalLink = mapInternalLinkToExplore({ link, @@ -154,7 +194,7 @@ export const getFieldLinksForExplore = (options: { scopedVars: allVars, range, field, - onClickFn: splitOpenFn, + onClickFn: (options) => splitFnWithTracking(options), replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()), }); return { ...internalLink, variables: variables };