github.com/cockroachdb/cockroach@v20.2.0-alpha.1+incompatible/pkg/ui/src/redux/analytics.ts (about) 1 // Copyright 2018 The Cockroach Authors. 2 // 3 // Use of this software is governed by the Business Source License 4 // included in the file licenses/BSL.txt. 5 // 6 // As of the Change Date specified in that file, in accordance with 7 // the Business Source License, use of this software will be governed 8 // by the Apache License, Version 2.0, included in the file 9 // licenses/APL.txt. 10 11 import Analytics from "analytics-node"; 12 import { Location } from "history"; 13 import _ from "lodash"; 14 import { Store } from "redux"; 15 16 import * as protos from "src/js/protos"; 17 import { versionsSelector } from "src/redux/nodes"; 18 import { store, history, AdminUIState } from "src/redux/state"; 19 import { COCKROACHLABS_ADDR } from "src/util/cockroachlabsAPI"; 20 21 type ClusterResponse = protos.cockroach.server.serverpb.IClusterResponse; 22 23 interface TrackMessage { 24 event: string; 25 properties?: Object; 26 timestamp?: Date; 27 context?: Object; 28 } 29 /** 30 * List of current redactions needed for pages tracked by the Admin UI. 31 * TODO(mrtracy): It this list becomes more extensive, it might benefit from a 32 * set of tests as a double-check. 33 */ 34 export const defaultRedactions = [ 35 // When viewing a specific database, the database name and table are part of 36 // the URL path. 37 { 38 match: new RegExp("/databases/database/.*/table/.*"), 39 replace: "/databases/database/[db]/table/[tbl]", 40 }, 41 // The new URL for a database page. 42 { 43 match: new RegExp("/database/.*/table/.*"), 44 replace: "/database/[db]/table/[tbl]", 45 }, 46 // The clusterviz map page, which puts localities in the URL. 47 { 48 match: new RegExp("/overview/map((/.+)+)"), 49 useFunction: true, // I hate TypeScript. 50 replace: function countTiers(original: string, localities: string) { 51 const tierCount = localities.match(new RegExp("/", "g")).length; 52 let redactedLocalities = ""; 53 for (let i = 0; i < tierCount; i++) { 54 redactedLocalities += "/[locality]"; 55 } 56 return original.replace(localities, redactedLocalities); 57 }, 58 }, 59 // The statement details page, with a full SQL statement in the URL. 60 { 61 match: new RegExp("/statement/.*"), 62 replace: "/statement/[statement]", 63 }, 64 ]; 65 66 type PageTrackReplacementFunction = (match: string, ...args: any[]) => string; 67 type PageTrackReplacement = string | PageTrackReplacementFunction; 68 69 /** 70 * A PageTrackRedaction describes a regular expression used to identify PII 71 * in strings that are being sent to analytics. If a string matches the given 72 * "match" RegExp, it will be replaced with the "replace" string before being 73 * sent to analytics. 74 */ 75 interface PageTrackRedaction { 76 match: RegExp; 77 replace: PageTrackReplacement; 78 useFunction?: boolean; // I hate Typescript. 79 } 80 81 /** 82 * AnalyticsSync is used to dispatch analytics event from the Admin UI to an 83 * analytics service (currently Segment). It combines information on individual 84 * events with user information from the redux state in order to properly 85 * identify events. 86 */ 87 export class AnalyticsSync { 88 /** 89 * queuedPages are used to store pages visited before the cluster ID 90 * is available. Once the cluster ID is available, the next call to page() 91 * will dispatch all queued locations to the underlying analytics API. 92 */ 93 private queuedPages: Location[] = []; 94 95 /** 96 * sentIdentifyEvent tracks whether the identification event has already 97 * been sent for this session. This event is not sent until all necessary 98 * information has been retrieved (current version of cockroachDB, 99 * cluster settings). 100 */ 101 private identifyEventSent = false; 102 103 /** 104 * Construct a new AnalyticsSync object. 105 * @param analyticsService Underlying interface to push to the analytics service. 106 * @param deprecatedStore The redux store for the Admin UI. [DEPRECATED] 107 * @param redactions A list of redaction regular expressions, used to 108 * scrub any potential personally-identifying information from the data 109 * being tracked. 110 */ 111 constructor( 112 private analyticsService: Analytics, 113 private deprecatedStore: Store<AdminUIState>, 114 private redactions: PageTrackRedaction[], 115 ) {} 116 117 /** 118 * page should be called whenever the user moves to a new page in the 119 * application. 120 * @param location The location (URL information) of the page. 121 */ 122 page(location: Location) { 123 // If the cluster ID is not yet available, queue the location to be 124 // pushed later. 125 const cluster = this.getCluster(); 126 if (cluster === null) { 127 this.queuedPages.push(location); 128 return; 129 } 130 131 const { cluster_id, reporting_enabled } = cluster; 132 133 // A cluster setting determines if diagnostic reporting is enabled. If 134 // it is not explicitly enabled, do nothing. 135 if (!reporting_enabled) { 136 if (this.queuedPages.length > 0) { 137 this.queuedPages = []; 138 } 139 return; 140 } 141 142 // If there are any queued pages, push them. 143 _.each(this.queuedPages, (l) => this.pushPage(cluster_id, l)); 144 this.queuedPages = []; 145 146 // Push the page that was just accessed. 147 this.pushPage(cluster_id, location); 148 } 149 150 /** 151 * identify attempts to send an "identify" event to the analytics service. 152 * The identify event will only be sent once per session; if it has already 153 * been sent, it will be a no-op whenever called afterwards. 154 */ 155 identify() { 156 if (this.identifyEventSent) { 157 return; 158 } 159 160 // Do nothing if Cluster information is not yet available. 161 const cluster = this.getCluster(); 162 if (cluster === null) { 163 return; 164 } 165 166 const { cluster_id, reporting_enabled, enterprise_enabled } = cluster; 167 if (!reporting_enabled) { 168 return; 169 } 170 171 // Do nothing if version information is not yet available. 172 const state = this.deprecatedStore.getState(); 173 const versions = versionsSelector(state); 174 if (_.isEmpty(versions)) { 175 return; 176 } 177 178 this.analyticsService.identify({ 179 userId: cluster_id, 180 traits: { 181 version: versions[0], 182 userAgent: window.navigator.userAgent, 183 enterprise: enterprise_enabled, 184 }, 185 }); 186 this.identifyEventSent = true; 187 } 188 189 /** Analytics Track for Segment: https://segment.com/docs/connections/spec/track/ */ 190 track(msg: TrackMessage) { 191 const cluster = this.getCluster(); 192 if (cluster === null) { 193 return; 194 } 195 196 // get cluster_id to id the event 197 const { cluster_id } = cluster; 198 const pagePath = this.redact(history.location.pathname); 199 200 // break down properties from message 201 const { properties, ...rest } = msg; 202 const props = { 203 pagePath, 204 ...properties, 205 }; 206 207 const message = { 208 userId: cluster_id, 209 properties: { ...props }, 210 ...rest, 211 }; 212 213 this.analyticsService.track(message); 214 } 215 216 /** 217 * Return the ClusterID from the store, returning null if the clusterID 218 * has not yet been fetched. We can depend on the alertdatasync component 219 * to eventually retrieve this without having to request it ourselves. 220 */ 221 private getCluster(): ClusterResponse | null { 222 const state = this.deprecatedStore.getState(); 223 224 // Do nothing if cluster ID has not been loaded. 225 const cluster = state.cachedData.cluster; 226 if (!cluster || !cluster.data) { 227 return null; 228 } 229 230 return cluster.data; 231 } 232 233 /** 234 * pushPage pushes a single "page" event to the analytics service. 235 */ 236 private pushPage = (userID: string, location: Location) => { 237 238 // Loop through redactions, if any matches return the appropriate 239 // redacted string. 240 const path = this.redact(location.pathname); 241 let search = ""; 242 243 if (location.search && location.search.length > 1) { 244 const query = location.search.slice(1); 245 const params = new URLSearchParams(query); 246 247 params.forEach((value, key) => { 248 params.set(key, this.redact(value)); 249 }); 250 search = "?" + params.toString(); 251 } 252 253 this.analyticsService.page({ 254 userId: userID, 255 name: path, 256 properties: { 257 path, 258 search, 259 }, 260 }); 261 } 262 263 private redact(path: string): string { 264 _.each(this.redactions, (r) => { 265 if (r.match.test(path)) { 266 267 // Apparently TypeScript doesn't know how to dispatch functions. 268 // If there are two function overloads defined (as with 269 // String.prototype.replace), it is unable to recognize that 270 // a union of the two types can be successfully passed in as a 271 // parameter of that function. We have to explicitly 272 // disambiguate the types for it. 273 // See https://github.com/Microsoft/TypeScript/issues/14107 274 if (r.useFunction) { 275 path = path.replace(r.match, r.replace as PageTrackReplacementFunction); 276 } else { 277 path = path.replace(r.match, r.replace as string); 278 } 279 return false; 280 } 281 }); 282 return path; 283 } 284 } 285 286 // Create a global instance of AnalyticsSync which can be used from various 287 // packages. If enabled, this instance will push to segment using the following 288 // analytics key. 289 const analyticsOpts = { 290 host: COCKROACHLABS_ADDR + "/api/segment", 291 }; 292 const analyticsInstance = new Analytics("5Vbp8WMYDmZTfCwE0uiUqEdAcTiZWFDb", analyticsOpts); 293 export const analytics = new AnalyticsSync(analyticsInstance, store, defaultRedactions); 294 295 // Attach a listener to the history object which will track a 'page' event 296 // whenever the user navigates to a new path. 297 let lastPageLocation: Location; 298 history.listen((location: Location) => { 299 // Do not log if the pathname is the same as the previous. 300 // Needed because history.listen() fires twice when using hash history, this 301 // bug is "won't fix" in the version of history we are using, and upgrading 302 // would imply a difficult upgrade to react-router v4. 303 // (https://github.com/ReactTraining/history/issues/427). 304 if (lastPageLocation && lastPageLocation.pathname === location.pathname) { 305 return; 306 } 307 lastPageLocation = location; 308 analytics.page(location); 309 // Identify the cluster. 310 analytics.identify(); 311 }); 312 313 // Record the initial page that was accessed; listen won't fire for the first 314 // page loaded. 315 analytics.page(history.location); 316 // Identify the cluster. 317 analytics.identify();