User analytics: Add Rudderstack integration (#36567)

* Replace analytics service with Echo backend

* Add Rudderstack integration and general pageview and interaction Echo events

* Update conf/defaults.ini

Co-authored-by: Dan Cech <dcech@grafana.com>

* Update packages/grafana-runtime/src/types/analytics.ts

Co-authored-by: Dan Cech <dcech@grafana.com>

* Update conf/defaults.ini

Co-authored-by: Dan Cech <dcech@grafana.com>

* Update tests

* Force cla check

Co-authored-by: Dan Cech <dcech@grafana.com>
This commit is contained in:
Dominik Prokop 2021-07-09 11:45:25 +02:00 committed by GitHub
parent cd95e28c30
commit 663a8935f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 298 additions and 79 deletions

View File

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

View File

@ -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,

View File

@ -79,6 +79,8 @@ export enum EchoEventType {
Performance = 'performance',
MetaAnalytics = 'meta-analytics',
Sentry = 'sentry',
Pageview = 'pageview',
Interaction = 'interaction',
}
/**

View File

@ -70,3 +70,54 @@ export type MetaAnalyticsEventPayload = DashboardViewEventPayload | DataRequestE
* @public
*/
export interface MetaAnalyticsEvent extends EchoEvent<EchoEventType.MetaAnalytics, MetaAnalyticsEventPayload> {}
/**
* 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<EchoEventType.Pageview, PageviewEchoEventPayload>;
/**
* Describes the payload of a user interaction event.
*
* @public
*/
export interface InteractionEchoEventPayload {
interactionName: string;
properties?: Record<string, any>;
}
/**
* Describes interaction event with predefined {@link EchoEventType.EchoEventType} type.
*
* @public
*/
export type InteractionEchoEvent = EchoEvent<EchoEventType.Interaction, InteractionEchoEventPayload>;
/**
* 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);
};

View File

@ -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<PageviewEchoEvent>({
type: EchoEventType.Pageview,
payload: {
page,
},
});
};
/**
* Helper function to report interaction events to the {@link EchoSrv}.
*
* @public
*/
export const reportInteraction = (interactionName: string, properties?: Record<string, any>) => {
getEchoSrv().addEvent<InteractionEchoEvent>({
type: EchoEventType.Interaction,
payload: {
interactionName,
properties,
},
});
};

View File

@ -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,

View File

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

View File

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

View File

@ -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 <div />;

View File

@ -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<GrafanaRouteComponentProps, 'queryParams'> {}
@ -15,13 +14,13 @@ export class GrafanaRoute extends React.Component<Props> {
// 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);
}

View File

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

View File

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

View File

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

View File

@ -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<PageviewEchoEvent, GAEchoBackendOptions> {
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 = () => {};
}

View File

@ -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<PageviewEchoEvent, RudderstackBackendOptions> {
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<string, any>)[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 = () => {};
}

View File

@ -35,6 +35,7 @@ describe('SentryEchoBackend', () => {
user: {
email: 'darth.vader@sith.glx',
id: 504,
orgId: 1,
},
};

View File

@ -11,4 +11,5 @@ export type SentryEchoEvent = EchoEvent<EchoEventType.Sentry, SentryEvent>;
export interface User {
email: string;
id: number;
orgId: number;
}

View File

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

View File

@ -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<Props> = ({ panel, dashboard })
) : (
<div className={styles.actionsWrapper}>
<div className={cx(styles.actionsRow, styles.columnGap)}>
<div onClick={() => onCreateNewPanel()} aria-label={selectors.pages.AddDashboard.addNewPanel}>
<div
onClick={() => {
reportInteraction('Create new panel');
onCreateNewPanel();
}}
aria-label={selectors.pages.AddDashboard.addNewPanel}
>
<Icon name="file-blank" size="xl" />
Add an empty panel
</div>
<div
className={styles.rowGap}
onClick={onCreateNewRow}
onClick={() => {
reportInteraction('Create new row');
onCreateNewRow();
}}
aria-label={selectors.pages.AddDashboard.addNewRow}
>
<Icon name="wrap-text" size="xl" />
@ -160,12 +169,24 @@ export const AddPanelWidgetUnconnected: React.FC<Props> = ({ panel, dashboard })
</div>
</div>
<div className={styles.actionsRow}>
<div onClick={() => setAddPanelView(true)} aria-label={selectors.pages.AddDashboard.addNewPanelLibrary}>
<div
onClick={() => {
reportInteraction('Add a panel from the panel library');
setAddPanelView(true);
}}
aria-label={selectors.pages.AddDashboard.addNewPanelLibrary}
>
<Icon name="book-open" size="xl" />
Add a panel from the panel library
</div>
{copiedPanelPlugins.length === 1 && (
<div className={styles.rowGap} onClick={() => onPasteCopiedPanel(copiedPanelPlugins[0])}>
<div
className={styles.rowGap}
onClick={() => {
reportInteraction('Paste panel from clipboard');
onPasteCopiedPanel(copiedPanelPlugins[0]);
}}
>
<Icon name="clipboard-alt" size="xl" />
Paste panel from clipboard
</div>

View File

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

View File

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