mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Chore: Pass signed user_hash to Intercom via Rudderstack (#63921)
* move analytics identifiers to backend
* implement hash function
* grab secret from env
* expose and retrieve intercom secret from config
* concat email with appUrl to ensure uniqueness
* revert to just using email
* Revert "revert to just using email"
This reverts commit 8f10f9b1bc.
* add docstring
This commit is contained in:
@@ -265,6 +265,9 @@ rudderstack_sdk_url =
|
|||||||
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
|
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
|
||||||
rudderstack_config_url =
|
rudderstack_config_url =
|
||||||
|
|
||||||
|
# Intercom secret, optional, used to hash user_id before passing to Intercom via Rudderstack
|
||||||
|
intercom_secret =
|
||||||
|
|
||||||
# Application Insights connection string. Specify an URL string to enable this feature.
|
# Application Insights connection string. Specify an URL string to enable this feature.
|
||||||
application_insights_connection_string =
|
application_insights_connection_string =
|
||||||
|
|
||||||
|
|||||||
@@ -272,6 +272,9 @@
|
|||||||
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
|
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
|
||||||
;rudderstack_config_url =
|
;rudderstack_config_url =
|
||||||
|
|
||||||
|
# Intercom secret, optional, used to hash user_id before passing to Intercom via Rudderstack
|
||||||
|
;intercom_secret =
|
||||||
|
|
||||||
# Controls if the UI contains any links to user feedback forms
|
# Controls if the UI contains any links to user feedback forms
|
||||||
;feedback_links_enabled = true
|
;feedback_links_enabled = true
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ export type OAuth =
|
|||||||
*/
|
*/
|
||||||
export type OAuthSettings = Partial<Record<OAuth, { name: string; icon?: IconName }>>;
|
export type OAuthSettings = Partial<Record<OAuth, { name: string; icon?: IconName }>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information needed for analytics providers
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export interface AnalyticsSettings {
|
||||||
|
identifier: string;
|
||||||
|
intercomIdentifier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Current user info included in bootData
|
/** Current user info included in bootData
|
||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
@@ -119,6 +129,7 @@ export interface CurrentUserDTO {
|
|||||||
locale: string;
|
locale: string;
|
||||||
language: string;
|
language: string;
|
||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
|
analytics: AnalyticsSettings;
|
||||||
|
|
||||||
/** @deprecated Use theme instead */
|
/** @deprecated Use theme instead */
|
||||||
lightTheme: boolean;
|
lightTheme: boolean;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export * from './geometry';
|
|||||||
export { isUnsignedPluginSignature } from './pluginSignature';
|
export { isUnsignedPluginSignature } from './pluginSignature';
|
||||||
export type {
|
export type {
|
||||||
CurrentUserDTO,
|
CurrentUserDTO,
|
||||||
|
AnalyticsSettings,
|
||||||
BootData,
|
BootData,
|
||||||
OAuth,
|
OAuth,
|
||||||
OAuthSettings,
|
OAuthSettings,
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ type CurrentUser struct {
|
|||||||
HelpFlags1 user.HelpFlags1 `json:"helpFlags1"`
|
HelpFlags1 user.HelpFlags1 `json:"helpFlags1"`
|
||||||
HasEditPermissionInFolders bool `json:"hasEditPermissionInFolders"`
|
HasEditPermissionInFolders bool `json:"hasEditPermissionInFolders"`
|
||||||
Permissions UserPermissionsMap `json:"permissions,omitempty"`
|
Permissions UserPermissionsMap `json:"permissions,omitempty"`
|
||||||
|
Analytics AnalyticsSettings `json:"analytics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnalyticsSettings struct {
|
||||||
|
Identifier string `json:"identifier"`
|
||||||
|
IntercomIdentifier string `json:"intercomIdentifier,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserPermissionsMap map[string]bool
|
type UserPermissionsMap map[string]bool
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
|
|||||||
Language: language,
|
Language: language,
|
||||||
HelpFlags1: c.HelpFlags1,
|
HelpFlags1: c.HelpFlags1,
|
||||||
HasEditPermissionInFolders: hasEditPerm,
|
HasEditPermissionInFolders: hasEditPerm,
|
||||||
|
Analytics: dtos.AnalyticsSettings{
|
||||||
|
Identifier: c.SignedInUser.Analytics.Identifier,
|
||||||
|
IntercomIdentifier: c.SignedInUser.Analytics.IntercomIdentifier,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Settings: settings,
|
Settings: settings,
|
||||||
Theme: prefs.Theme,
|
Theme: prefs.Theme,
|
||||||
|
|||||||
@@ -193,6 +193,11 @@ type GetSignedInUserQuery struct {
|
|||||||
OrgID int64 `xorm:"org_id"`
|
OrgID int64 `xorm:"org_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AnalyticsSettings struct {
|
||||||
|
Identifier string
|
||||||
|
IntercomIdentifier string
|
||||||
|
}
|
||||||
|
|
||||||
type SignedInUser struct {
|
type SignedInUser struct {
|
||||||
UserID int64 `xorm:"user_id"`
|
UserID int64 `xorm:"user_id"`
|
||||||
OrgID int64 `xorm:"org_id"`
|
OrgID int64 `xorm:"org_id"`
|
||||||
@@ -212,6 +217,7 @@ type SignedInUser struct {
|
|||||||
HelpFlags1 HelpFlags1
|
HelpFlags1 HelpFlags1
|
||||||
LastSeenAt time.Time
|
LastSeenAt time.Time
|
||||||
Teams []int64
|
Teams []int64
|
||||||
|
Analytics AnalyticsSettings
|
||||||
// Permissions grouped by orgID and actions
|
// Permissions grouped by orgID and actions
|
||||||
Permissions map[int64]map[string][]string `json:"-"`
|
Permissions map[int64]map[string][]string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -432,6 +432,8 @@ func (ss *sqlStore) GetSignedInUser(ctx context.Context, query *user.GetSignedIn
|
|||||||
if signedInUser.ExternalAuthModule != "oauth_grafana_com" {
|
if signedInUser.ExternalAuthModule != "oauth_grafana_com" {
|
||||||
signedInUser.ExternalAuthID = ""
|
signedInUser.ExternalAuthID = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signedInUser.Analytics = buildUserAnalyticsSettings(signedInUser, ss.cfg.IntercomSecret)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
return &signedInUser, err
|
return &signedInUser, err
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package userimpl
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -560,3 +563,25 @@ func (s *Service) supportBundleCollector() supportbundles.Collector {
|
|||||||
Fn: collectorFn,
|
Fn: collectorFn,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hashUserIdentifier(identifier string, secret string) string {
|
||||||
|
key := []byte(secret)
|
||||||
|
h := hmac.New(sha256.New, key)
|
||||||
|
h.Write([]byte(identifier))
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUserAnalyticsSettings(signedInUser user.SignedInUser, intercomSecret string) user.AnalyticsSettings {
|
||||||
|
var settings user.AnalyticsSettings
|
||||||
|
|
||||||
|
if signedInUser.ExternalAuthID != "" {
|
||||||
|
settings.Identifier = signedInUser.ExternalAuthID
|
||||||
|
} else {
|
||||||
|
settings.Identifier = signedInUser.Email + "@" + setting.AppUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if intercomSecret != "" {
|
||||||
|
settings.IntercomIdentifier = hashUserIdentifier(settings.Identifier, intercomSecret)
|
||||||
|
}
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|||||||
@@ -401,6 +401,7 @@ type Cfg struct {
|
|||||||
RudderstackWriteKey string
|
RudderstackWriteKey string
|
||||||
RudderstackSDKURL string
|
RudderstackSDKURL string
|
||||||
RudderstackConfigURL string
|
RudderstackConfigURL string
|
||||||
|
IntercomSecret string
|
||||||
|
|
||||||
// AzureAD
|
// AzureAD
|
||||||
AzureADSkipOrgRoleSync bool
|
AzureADSkipOrgRoleSync bool
|
||||||
@@ -1034,6 +1035,7 @@ func (cfg *Cfg) Load(args CommandLineArgs) error {
|
|||||||
cfg.RudderstackDataPlaneURL = analytics.Key("rudderstack_data_plane_url").String()
|
cfg.RudderstackDataPlaneURL = analytics.Key("rudderstack_data_plane_url").String()
|
||||||
cfg.RudderstackSDKURL = analytics.Key("rudderstack_sdk_url").String()
|
cfg.RudderstackSDKURL = analytics.Key("rudderstack_sdk_url").String()
|
||||||
cfg.RudderstackConfigURL = analytics.Key("rudderstack_config_url").String()
|
cfg.RudderstackConfigURL = analytics.Key("rudderstack_config_url").String()
|
||||||
|
cfg.IntercomSecret = analytics.Key("intercom_secret").String()
|
||||||
|
|
||||||
cfg.ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true)
|
cfg.ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true)
|
||||||
cfg.ReportingDistributor = analytics.Key("reporting_distributor").MustString("grafana-labs")
|
cfg.ReportingDistributor = analytics.Key("reporting_distributor").MustString("grafana-labs")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { extend } from 'lodash';
|
import { extend } from 'lodash';
|
||||||
|
|
||||||
import { OrgRole, rangeUtil, WithAccessControlMetadata } from '@grafana/data';
|
import { AnalyticsSettings, OrgRole, rangeUtil, WithAccessControlMetadata } from '@grafana/data';
|
||||||
import { featureEnabled, getBackendSrv } from '@grafana/runtime';
|
import { featureEnabled, getBackendSrv } from '@grafana/runtime';
|
||||||
import { AccessControlAction, UserPermission } from 'app/types';
|
import { AccessControlAction, UserPermission } from 'app/types';
|
||||||
import { CurrentUserInternal } from 'app/types/config';
|
import { CurrentUserInternal } from 'app/types/config';
|
||||||
@@ -28,6 +28,7 @@ export class User implements Omit<CurrentUserInternal, 'lightTheme'> {
|
|||||||
helpFlags1: number;
|
helpFlags1: number;
|
||||||
hasEditPermissionInFolders: boolean;
|
hasEditPermissionInFolders: boolean;
|
||||||
permissions?: UserPermission;
|
permissions?: UserPermission;
|
||||||
|
analytics: AnalyticsSettings;
|
||||||
fiscalYearStartMonth: number;
|
fiscalYearStartMonth: number;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -51,6 +52,9 @@ export class User implements Omit<CurrentUserInternal, 'lightTheme'> {
|
|||||||
this.language = '';
|
this.language = '';
|
||||||
this.weekStart = '';
|
this.weekStart = '';
|
||||||
this.gravatarUrl = '';
|
this.gravatarUrl = '';
|
||||||
|
this.analytics = {
|
||||||
|
identifier: '',
|
||||||
|
};
|
||||||
|
|
||||||
if (config.bootData.user) {
|
if (config.bootData.user) {
|
||||||
extend(this, config.bootData.user);
|
extend(this, config.bootData.user);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CurrentUserDTO } from '@grafana/data';
|
import { CurrentUserDTO } from '@grafana/data';
|
||||||
import { EchoBackend, EchoEventType, PageviewEchoEvent } from '@grafana/runtime';
|
import { EchoBackend, EchoEventType, PageviewEchoEvent } from '@grafana/runtime';
|
||||||
|
|
||||||
import { getUserIdentifier, loadScript } from '../../utils';
|
import { loadScript } from '../../utils';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -34,7 +34,7 @@ export class GA4EchoBackend implements EchoBackend<PageviewEchoEvent, GA4EchoBac
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (options.user) {
|
if (options.user) {
|
||||||
configOptions.user_id = getUserIdentifier(options.user);
|
configOptions.user_id = options.user.analytics.identifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.googleAnalytics4SendManualPageViews = options.googleAnalytics4SendManualPageViews;
|
this.googleAnalytics4SendManualPageViews = options.googleAnalytics4SendManualPageViews;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { identify, load, page, track } from 'rudder-sdk-js'; // SDK is loaded dynamically from config, so we only import types from the SDK package
|
import type { apiOptions, identify, load, page, track } from 'rudder-sdk-js'; // SDK is loaded dynamically from config, so we only import types from the SDK package
|
||||||
|
|
||||||
import { CurrentUserDTO } from '@grafana/data';
|
import { CurrentUserDTO } from '@grafana/data';
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
PageviewEchoEvent,
|
PageviewEchoEvent,
|
||||||
} from '@grafana/runtime';
|
} from '@grafana/runtime';
|
||||||
|
|
||||||
import { getUserIdentifier, loadScript } from '../../utils';
|
import { loadScript } from '../../utils';
|
||||||
|
|
||||||
interface Rudderstack {
|
interface Rudderstack {
|
||||||
identify: typeof identify;
|
identify: typeof identify;
|
||||||
@@ -70,13 +70,24 @@ export class RudderstackBackend implements EchoBackend<PageviewEchoEvent, Rudder
|
|||||||
window.rudderanalytics?.load?.(options.writeKey, options.dataPlaneUrl, { configUrl: options.configUrl });
|
window.rudderanalytics?.load?.(options.writeKey, options.dataPlaneUrl, { configUrl: options.configUrl });
|
||||||
|
|
||||||
if (options.user) {
|
if (options.user) {
|
||||||
const identifier = getUserIdentifier(options.user);
|
const { identifier, intercomIdentifier } = options.user.analytics;
|
||||||
|
const apiOptions: apiOptions = {};
|
||||||
|
|
||||||
window.rudderanalytics?.identify?.(identifier, {
|
if (intercomIdentifier) {
|
||||||
email: options.user.email,
|
apiOptions.Intercom = {
|
||||||
orgId: options.user.orgId,
|
user_hash: intercomIdentifier,
|
||||||
language: options.user.language,
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
|
window.rudderanalytics?.identify?.(
|
||||||
|
identifier,
|
||||||
|
{
|
||||||
|
email: options.user.email,
|
||||||
|
orgId: options.user.orgId,
|
||||||
|
language: options.user.language,
|
||||||
|
},
|
||||||
|
apiOptions
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { CurrentUserDTO, OrgRole } from '@grafana/data';
|
|
||||||
|
|
||||||
import { getUserIdentifier } from './utils';
|
|
||||||
|
|
||||||
const baseUser: CurrentUserDTO = {
|
|
||||||
isSignedIn: true,
|
|
||||||
id: 3,
|
|
||||||
login: 'myUsername',
|
|
||||||
email: 'email@example.com',
|
|
||||||
name: 'My Name',
|
|
||||||
theme: 'dark',
|
|
||||||
lightTheme: false, // deprecated
|
|
||||||
orgCount: 1,
|
|
||||||
orgId: 1,
|
|
||||||
orgName: 'Main Org.',
|
|
||||||
orgRole: OrgRole.Admin,
|
|
||||||
isGrafanaAdmin: false,
|
|
||||||
gravatarUrl: '/avatar/abc-123',
|
|
||||||
timezone: 'browser',
|
|
||||||
weekStart: 'browser',
|
|
||||||
locale: 'en-AU',
|
|
||||||
language: 'en-US',
|
|
||||||
externalUserId: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const gcomUser: CurrentUserDTO = {
|
|
||||||
...baseUser,
|
|
||||||
externalUserId: 'abc-123',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('echo getUserIdentifier', () => {
|
|
||||||
it('should return the external user ID (gcom ID) if available', () => {
|
|
||||||
const id = getUserIdentifier(gcomUser);
|
|
||||||
expect(id).toBe('abc-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fall back to the email address', () => {
|
|
||||||
const id = getUserIdentifier(baseUser);
|
|
||||||
expect(id).toBe('email@example.com');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,19 +1,5 @@
|
|||||||
import { CurrentUserDTO } from '@grafana/data';
|
|
||||||
import { attachDebugger, createLogger } from '@grafana/ui';
|
import { attachDebugger, createLogger } from '@grafana/ui';
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an opaque identifier for a user, for reporting purposes.
|
|
||||||
* Because this is for use when reporting across multiple Grafana installations
|
|
||||||
* It cannot simply be user.id because that's not unique across two installations.
|
|
||||||
*/
|
|
||||||
export function getUserIdentifier(user: CurrentUserDTO) {
|
|
||||||
if (user.externalUserId.length) {
|
|
||||||
return user.externalUserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return user.email;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadScript(url: string, async = false) {
|
export function loadScript(url: string, async = false) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
|
|||||||
Reference in New Issue
Block a user