diff --git a/conf/defaults.ini b/conf/defaults.ini index 5c795af5e23..1c5ffce9621 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -196,6 +196,12 @@ google_analytics_ua_id = # Google Tag Manager ID, only enabled if you specify an id here google_tag_manager_id = +# Rudderstack write key, enabled only if rudderstack_data_plane_url is also set +rudderstack_write_key = + +# Rudderstack data plane url, enabled only if rudderstack_write_key is also set +rudderstack_data_plane_url = + #################################### Security ############################ [security] # disable creation of admin user on first start of grafana diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index 412af795ede..68b0a07cf44 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -7,7 +7,7 @@ export * from './services'; export * from './config'; export * from './types'; export { loadPluginCss, SystemJS, PluginCssOptions } from './utils/plugin'; -export { reportMetaAnalytics } from './utils/analytics'; +export { reportMetaAnalytics, reportInteraction, reportPageview } from './utils/analytics'; export { logInfo, logDebug, logWarning, logError } from './utils/logging'; export { DataSourceWithBackend, diff --git a/packages/grafana-runtime/src/services/EchoSrv.ts b/packages/grafana-runtime/src/services/EchoSrv.ts index 098051d5c62..7e8ae524e64 100644 --- a/packages/grafana-runtime/src/services/EchoSrv.ts +++ b/packages/grafana-runtime/src/services/EchoSrv.ts @@ -79,6 +79,8 @@ export enum EchoEventType { Performance = 'performance', MetaAnalytics = 'meta-analytics', Sentry = 'sentry', + Pageview = 'pageview', + Interaction = 'interaction', } /** diff --git a/packages/grafana-runtime/src/types/analytics.ts b/packages/grafana-runtime/src/types/analytics.ts index 767dc7482f4..99e158e2d6d 100644 --- a/packages/grafana-runtime/src/types/analytics.ts +++ b/packages/grafana-runtime/src/types/analytics.ts @@ -70,3 +70,54 @@ export type MetaAnalyticsEventPayload = DashboardViewEventPayload | DataRequestE * @public */ export interface MetaAnalyticsEvent extends EchoEvent {} + +/** + * Describes the payload of a pageview event. + * + * @public + */ +export interface PageviewEchoEventPayload { + page: string; +} + +/** + * Describes pageview event with predefined {@link EchoEventType.EchoEventType} type. + * + * @public + */ +export type PageviewEchoEvent = EchoEvent; + +/** + * Describes the payload of a user interaction event. + * + * @public + */ +export interface InteractionEchoEventPayload { + interactionName: string; + properties?: Record; +} + +/** + * Describes interaction event with predefined {@link EchoEventType.EchoEventType} type. + * + * @public + */ +export type InteractionEchoEvent = EchoEvent; + +/** + * Pageview event typeguard. + * + * @public + */ +export const isPageviewEvent = (event: EchoEvent): event is PageviewEchoEvent => { + return Boolean(event.payload.page); +}; + +/** + * Interaction event typeguard. + * + * @public + */ +export const isInteractionEvent = (event: EchoEvent): event is InteractionEchoEvent => { + return Boolean(event.payload.interactionName); +}; diff --git a/packages/grafana-runtime/src/utils/analytics.ts b/packages/grafana-runtime/src/utils/analytics.ts index b3f090b3314..e864cd5c4b2 100644 --- a/packages/grafana-runtime/src/utils/analytics.ts +++ b/packages/grafana-runtime/src/utils/analytics.ts @@ -1,5 +1,12 @@ import { getEchoSrv, EchoEventType } from '../services/EchoSrv'; -import { MetaAnalyticsEvent, MetaAnalyticsEventPayload } from '../types/analytics'; +import { + InteractionEchoEvent, + MetaAnalyticsEvent, + MetaAnalyticsEventPayload, + PageviewEchoEvent, +} from '../types/analytics'; +import { locationService } from '../services'; +import { config } from '../config'; /** * Helper function to report meta analytics to the {@link EchoSrv}. @@ -12,3 +19,34 @@ export const reportMetaAnalytics = (payload: MetaAnalyticsEventPayload) => { payload, }); }; + +/** + * Helper function to report pageview events to the {@link EchoSrv}. + * + * @public + */ +export const reportPageview = () => { + const location = locationService.getLocation(); + const page = `${config.appSubUrl ?? ''}${location.pathname}${location.search}${location.hash}`; + getEchoSrv().addEvent({ + type: EchoEventType.Pageview, + payload: { + page, + }, + }); +}; + +/** + * Helper function to report interaction events to the {@link EchoSrv}. + * + * @public + */ +export const reportInteraction = (interactionName: string, properties?: Record) => { + getEchoSrv().addEvent({ + type: EchoEventType.Interaction, + payload: { + interactionName, + properties, + }, + }); +}; diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 39affbd1279..c2d68b25a7b 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -209,6 +209,8 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "sigV4AuthEnabled": setting.SigV4AuthEnabled, "exploreEnabled": setting.ExploreEnabled, "googleAnalyticsId": setting.GoogleAnalyticsId, + "rudderstackWriteKey": setting.RudderstackWriteKey, + "rudderstackDataPlaneUrl": setting.RudderstackDataPlaneUrl, "disableLoginForm": setting.DisableLoginForm, "disableUserSignUp": !setting.AllowUserSignUp, "loginHint": setting.LoginHint, diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 1a5fd7042e1..aea2cd630c7 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -148,8 +148,10 @@ var ( appliedEnvOverrides []string // analytics - GoogleAnalyticsId string - GoogleTagManagerId string + GoogleAnalyticsId string + GoogleTagManagerId string + RudderstackDataPlaneUrl string + RudderstackWriteKey string // LDAP LDAPEnabled bool @@ -894,6 +896,8 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { cfg.CheckForUpdates = analytics.Key("check_for_updates").MustBool(true) GoogleAnalyticsId = analytics.Key("google_analytics_ua_id").String() GoogleTagManagerId = analytics.Key("google_tag_manager_id").String() + RudderstackWriteKey = analytics.Key("rudderstack_write_key").String() + RudderstackDataPlaneUrl = analytics.Key("rudderstack_data_plane_url").String() cfg.ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true) cfg.ReportingDistributor = analytics.Key("reporting_distributor").MustString("grafana-labs") if len(cfg.ReportingDistributor) >= 100 { diff --git a/public/app/app.ts b/public/app/app.ts index 59f95f00e86..6ea299b0da4 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -47,6 +47,8 @@ import { getTimeSrv } from './features/dashboard/services/TimeSrv'; import { getVariablesUrlParams } from './features/variables/getAllVariableValuesForUrl'; import getDefaultMonacoLanguages from '../lib/monaco-languages'; import { contextSrv } from './core/services/context_srv'; +import { GAEchoBackend } from './core/services/echo/backends/analytics/GABackend'; +import { RudderstackBackend } from './core/services/echo/backends/analytics/RudderstackBackend'; // add move to lodash for backward compatabilty with plugins // @ts-ignore @@ -161,6 +163,24 @@ function initEchoSrv() { }) ); } + + if ((config as any).googleAnalyticsId) { + registerEchoBackend( + new GAEchoBackend({ + googleAnalyticsId: (config as any).googleAnalyticsId, + }) + ); + } + + if ((config as any).rudderstackWriteKey && (config as any).rudderstackDataPlaneUrl) { + registerEchoBackend( + new RudderstackBackend({ + writeKey: (config as any).rudderstackWriteKey, + dataPlaneUrl: (config as any).rudderstackDataPlaneUrl, + user: config.bootData.user, + }) + ); + } } function addClassIfNoOverlayScrollbar() { diff --git a/public/app/core/navigation/GrafanaRoute.test.tsx b/public/app/core/navigation/GrafanaRoute.test.tsx index 26f6552857b..6b434645d25 100644 --- a/public/app/core/navigation/GrafanaRoute.test.tsx +++ b/public/app/core/navigation/GrafanaRoute.test.tsx @@ -1,11 +1,16 @@ import React from 'react'; import { render } from '@testing-library/react'; import { GrafanaRoute } from './GrafanaRoute'; +import { setEchoSrv } from '@grafana/runtime'; +import { Echo } from '../services/echo/Echo'; describe('GrafanaRoute', () => { + beforeEach(() => { + setEchoSrv(new Echo()); + }); + it('Parses search', () => { let capturedProps: any; - const PageComponent = (props: any) => { capturedProps = props; return
; diff --git a/public/app/core/navigation/GrafanaRoute.tsx b/public/app/core/navigation/GrafanaRoute.tsx index b20100480f5..500218812cb 100644 --- a/public/app/core/navigation/GrafanaRoute.tsx +++ b/public/app/core/navigation/GrafanaRoute.tsx @@ -2,9 +2,8 @@ import React from 'react'; // @ts-ignore import Drop from 'tether-drop'; import { GrafanaRouteComponentProps } from './types'; -import { locationSearchToObject, navigationLogger } from '@grafana/runtime'; +import { locationSearchToObject, navigationLogger, reportPageview } from '@grafana/runtime'; import { keybindingSrv } from '../services/keybindingSrv'; -import { analyticsService } from '../services/analytics'; export interface Props extends Omit {} @@ -15,13 +14,13 @@ export class GrafanaRoute extends React.Component { // unbinds all and re-bind global keybindins keybindingSrv.reset(); keybindingSrv.initGlobals(); - analyticsService.track(); + reportPageview(); navigationLogger('GrafanaRoute', false, 'Mounted', this.props.match); } componentDidUpdate(prevProps: Props) { this.cleanupDOM(); - analyticsService.track(); + reportPageview(); navigationLogger('GrafanaRoute', false, 'Updated', this.props, prevProps); } diff --git a/public/app/core/services/all.ts b/public/app/core/services/all.ts index f43e8d6ee82..20086bbe6f0 100644 --- a/public/app/core/services/all.ts +++ b/public/app/core/services/all.ts @@ -2,7 +2,6 @@ import './alert_srv'; import './util_srv'; import './context_srv'; import './timer'; -import './analytics'; import './popover_srv'; import './segment_srv'; import './backend_srv'; diff --git a/public/app/core/services/analytics.ts b/public/app/core/services/analytics.ts deleted file mode 100644 index 0d03b4d0458..00000000000 --- a/public/app/core/services/analytics.ts +++ /dev/null @@ -1,52 +0,0 @@ -import $ from 'jquery'; -import config from 'app/core/config'; -import { locationService } from '@grafana/runtime'; - -export class Analytics { - private gaId?: string; - private ga?: any; - - constructor() { - this.track = this.track.bind(this); - this.gaId = (config as any).googleAnalyticsId; - this.init(); - } - - init() { - if (!this.gaId) { - return; - } - - $.ajax({ - url: 'https://www.google-analytics.com/analytics.js', - dataType: 'script', - cache: true, - }); - - const ga = ((window as any).ga = - (window as any).ga || - // this had the equivalent of `eslint-disable-next-line prefer-arrow/prefer-arrow-functions` - function () { - (ga.q = ga.q || []).push(arguments); - }); - ga.l = +new Date(); - ga('create', (config as any).googleAnalyticsId, 'auto'); - ga('set', 'anonymizeIp', true); - this.ga = ga; - return ga; - } - - track() { - if (!this.ga) { - return; - } - - const location = locationService.getLocation(); - const track = { page: `${config.appSubUrl ?? ''}${location.pathname}${location.search}${location.hash}` }; - - this.ga('set', track); - this.ga('send', 'pageview'); - } -} - -export const analyticsService = new Analytics(); diff --git a/public/app/core/services/echo/Echo.ts b/public/app/core/services/echo/Echo.ts index 3db751f5420..92d24d470f9 100644 --- a/public/app/core/services/echo/Echo.ts +++ b/public/app/core/services/echo/Echo.ts @@ -1,5 +1,6 @@ import { EchoBackend, EchoMeta, EchoEvent, EchoSrv } from '@grafana/runtime'; import { contextSrv } from '../context_srv'; +import { echoLog } from './utils'; interface EchoConfig { // How often should metrics be reported @@ -30,13 +31,6 @@ export class Echo implements EchoSrv { setInterval(this.flush, this.config.flushInterval); } - logDebug = (...msg: any) => { - if (this.config.debug) { - // eslint-disable-next-line - // console.debug('ECHO:', ...msg); - } - }; - flush = () => { for (const backend of this.backends) { backend.flush(); @@ -44,7 +38,7 @@ export class Echo implements EchoSrv { }; addBackend = (backend: EchoBackend) => { - this.logDebug('Adding backend', backend); + echoLog('Adding backend', false, backend); this.backends.push(backend); }; @@ -63,8 +57,7 @@ export class Echo implements EchoSrv { backend.addEvent(_event); } } - - this.logDebug('Adding event', _event); + echoLog('Reporting event', false, _event); }; getMeta = (): EchoMeta => { diff --git a/public/app/core/services/echo/backends/analytics/GABackend.ts b/public/app/core/services/echo/backends/analytics/GABackend.ts new file mode 100644 index 00000000000..422e9b2d9f9 --- /dev/null +++ b/public/app/core/services/echo/backends/analytics/GABackend.ts @@ -0,0 +1,43 @@ +import $ from 'jquery'; +import { EchoBackend, EchoEventType, PageviewEchoEvent } from '@grafana/runtime'; + +export interface GAEchoBackendOptions { + googleAnalyticsId: string; + debug?: boolean; +} + +export class GAEchoBackend implements EchoBackend { + supportedEvents = [EchoEventType.Pageview]; + + constructor(public options: GAEchoBackendOptions) { + const url = `https://www.google-analytics.com/analytics${options.debug ? '_debug' : ''}.js`; + + $.ajax({ + url, + dataType: 'script', + cache: true, + }); + + const ga = ((window as any).ga = + (window as any).ga || + // this had the equivalent of `eslint-disable-next-line prefer-arrow/prefer-arrow-functions` + function () { + (ga.q = ga.q || []).push(arguments); + }); + ga.l = +new Date(); + ga('create', options.googleAnalyticsId, 'auto'); + ga('set', 'anonymizeIp', true); + } + + addEvent = (e: PageviewEchoEvent) => { + if (!(window as any).ga) { + return; + } + + (window as any).ga('set', { page: e.payload.page }); + (window as any).ga('send', 'pageview'); + }; + + // Not using Echo buffering, addEvent above sends events to GA as soon as they appear + flush = () => {}; +} diff --git a/public/app/core/services/echo/backends/analytics/RudderstackBackend.ts b/public/app/core/services/echo/backends/analytics/RudderstackBackend.ts new file mode 100644 index 00000000000..7e66605f947 --- /dev/null +++ b/public/app/core/services/echo/backends/analytics/RudderstackBackend.ts @@ -0,0 +1,74 @@ +import $ from 'jquery'; +import { EchoBackend, EchoEventType, isInteractionEvent, isPageviewEvent, PageviewEchoEvent } from '@grafana/runtime'; +import { User } from '../sentry/types'; + +export interface RudderstackBackendOptions { + writeKey: string; + dataPlaneUrl: string; + user?: User; +} + +export class RudderstackBackend implements EchoBackend { + supportedEvents = [EchoEventType.Pageview, EchoEventType.Interaction]; + + constructor(public options: RudderstackBackendOptions) { + const url = `https://cdn.rudderlabs.com/v1/rudder-analytics.min.js`; + + $.ajax({ + url, + dataType: 'script', + cache: true, + }); + + const rds = ((window as any).rudderanalytics = []); + + var methods = [ + 'load', + 'page', + 'track', + 'identify', + 'alias', + 'group', + 'ready', + 'reset', + 'getAnonymousId', + 'setAnonymousId', + ]; + + for (let i = 0; i < methods.length; i++) { + const method = methods[i]; + (rds as Record)[method] = (function (methodName) { + return function () { + // @ts-ignore + rds.push([methodName].concat(Array.prototype.slice.call(arguments))); + }; + })(method); + } + + (rds as any).load(options.writeKey, options.dataPlaneUrl); + + if (options.user) { + (rds as any).identify(String(options.user.id), { + email: options.user.email, + orgId: options.user.orgId, + }); + } + } + + addEvent = (e: PageviewEchoEvent) => { + if (!(window as any).rudderanalytics) { + return; + } + + if (isPageviewEvent(e)) { + (window as any).rudderanalytics.page(); + } + + if (isInteractionEvent(e)) { + (window as any).rudderanalytics.track(e.payload.interactionName, e.payload.properties); + } + }; + + // Not using Echo buffering, addEvent above sends events to GA as soon as they appear + flush = () => {}; +} diff --git a/public/app/core/services/echo/backends/sentry/SentryBackend.test.ts b/public/app/core/services/echo/backends/sentry/SentryBackend.test.ts index 26f302ff1f9..249f200fe3a 100644 --- a/public/app/core/services/echo/backends/sentry/SentryBackend.test.ts +++ b/public/app/core/services/echo/backends/sentry/SentryBackend.test.ts @@ -35,6 +35,7 @@ describe('SentryEchoBackend', () => { user: { email: 'darth.vader@sith.glx', id: 504, + orgId: 1, }, }; diff --git a/public/app/core/services/echo/backends/sentry/types.ts b/public/app/core/services/echo/backends/sentry/types.ts index 4f036183ec9..9d5906a2d53 100644 --- a/public/app/core/services/echo/backends/sentry/types.ts +++ b/public/app/core/services/echo/backends/sentry/types.ts @@ -11,4 +11,5 @@ export type SentryEchoEvent = EchoEvent; export interface User { email: string; id: number; + orgId: number; } diff --git a/public/app/core/services/echo/utils.ts b/public/app/core/services/echo/utils.ts new file mode 100644 index 00000000000..1765b0017fe --- /dev/null +++ b/public/app/core/services/echo/utils.ts @@ -0,0 +1,7 @@ +import { attachDebugger, createLogger } from '@grafana/ui'; + +/** @internal */ +export const echoLogger = createLogger('EchoSrv'); +export const echoLog = echoLogger.logger; + +attachDebugger('echo', undefined, echoLogger); diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx index fd7b1b614d5..eb71b4378e6 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx @@ -3,7 +3,7 @@ import { connect, MapDispatchToProps } from 'react-redux'; import { css, cx, keyframes } from '@emotion/css'; import { chain, cloneDeep, defaults, find, sortBy } from 'lodash'; import tinycolor from 'tinycolor2'; -import { locationService } from '@grafana/runtime'; +import { locationService, reportInteraction } from '@grafana/runtime'; import { Icon, IconButton, styleMixins, useStyles } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; import { GrafanaTheme } from '@grafana/data'; @@ -146,13 +146,22 @@ export const AddPanelWidgetUnconnected: React.FC = ({ panel, dashboard }) ) : (
-
onCreateNewPanel()} aria-label={selectors.pages.AddDashboard.addNewPanel}> +
{ + reportInteraction('Create new panel'); + onCreateNewPanel(); + }} + aria-label={selectors.pages.AddDashboard.addNewPanel} + > Add an empty panel
{ + reportInteraction('Create new row'); + onCreateNewRow(); + }} aria-label={selectors.pages.AddDashboard.addNewRow} > @@ -160,12 +169,24 @@ export const AddPanelWidgetUnconnected: React.FC = ({ panel, dashboard })
-
setAddPanelView(true)} aria-label={selectors.pages.AddDashboard.addNewPanelLibrary}> +
{ + reportInteraction('Add a panel from the panel library'); + setAddPanelView(true); + }} + aria-label={selectors.pages.AddDashboard.addNewPanelLibrary} + > Add a panel from the panel library
{copiedPanelPlugins.length === 1 && ( -
onPasteCopiedPanel(copiedPanelPlugins[0])}> +
{ + reportInteraction('Paste panel from clipboard'); + onPasteCopiedPanel(copiedPanelPlugins[0]); + }} + > Paste panel from clipboard
diff --git a/public/app/features/explore/Wrapper.test.tsx b/public/app/features/explore/Wrapper.test.tsx index 1af45e90784..32feaf20d7f 100644 --- a/public/app/features/explore/Wrapper.test.tsx +++ b/public/app/features/explore/Wrapper.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import Wrapper from './Wrapper'; import { configureStore } from '../../store/configureStore'; import { Provider } from 'react-redux'; -import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { locationService, setDataSourceSrv, setEchoSrv } from '@grafana/runtime'; import { ArrayDataFrame, DataQueryResponse, @@ -26,6 +26,7 @@ import { splitOpen } from './state/main'; import { Route, Router } from 'react-router-dom'; import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute'; import { initialUserState } from '../profile/state/reducers'; +import { Echo } from 'app/core/services/echo/Echo'; type Mock = jest.Mock; @@ -265,6 +266,7 @@ type SetupOptions = { datasources?: DatasourceSetup[]; query?: any; }; + function setup(options?: SetupOptions): { datasources: { [name: string]: DataSourceApi }; store: EnhancedStore } { // Clear this up otherwise it persists data source selection // TODO: probably add test for that too @@ -296,6 +298,7 @@ function setup(options?: SetupOptions): { datasources: { [name: string]: DataSou return intervals; }, } as any); + setEchoSrv(new Echo()); const store = configureStore(); store.getState().user = { diff --git a/public/app/features/plugins/AppRootPage.test.tsx b/public/app/features/plugins/AppRootPage.test.tsx index d4614f28f73..b0cc93fa087 100644 --- a/public/app/features/plugins/AppRootPage.test.tsx +++ b/public/app/features/plugins/AppRootPage.test.tsx @@ -6,8 +6,9 @@ import { importAppPlugin } from './plugin_loader'; import { getMockPlugin } from './__mocks__/pluginMocks'; import { AppPlugin, PluginType, AppRootProps, NavModelItem } from '@grafana/data'; import { Route, Router } from 'react-router-dom'; -import { locationService } from '@grafana/runtime'; +import { locationService, setEchoSrv } from '@grafana/runtime'; import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute'; +import { Echo } from 'app/core/services/echo/Echo'; jest.mock('./PluginSettingsCache', () => ({ getPluginSettings: jest.fn(), @@ -70,6 +71,7 @@ function renderUnderRouter() { describe('AppRootPage', () => { beforeEach(() => { jest.resetAllMocks(); + setEchoSrv(new Echo()); }); it('should not mount plugin twice if nav is changed', async () => {