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