chromium/ash/webui/camera_app_ui/resources/js/untrusted_ga_helper.ts

// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assert, assertExists} from './assert.js';

/**
 * The GA library URL in trusted type.
 */
const gaLibraryURL = (() => {
  const staticUrlPolicy =
      assertExists(window.trustedTypes).createPolicy('ga-js-static', {
        createScriptURL: (_url: string) => '../js/lib/analytics.js',
      });
  return staticUrlPolicy.createScriptURL('');
})();

declare global {
  interface Window {
    // Used for GA to support renaming, see
    // https://developers.google.com/analytics/devguides/collection/analyticsjs/tracking-snippet-reference#tracking-unminified
    // eslint-disable-next-line @typescript-eslint/naming-convention
    GoogleAnalyticsObject: 'ga';
    // GA use global `ga-disable-GA_MEASUREMENT_ID` to disable a particular
    // measurement. See
    // https://developers.google.com/analytics/devguides/collection/gtagjs/user-opt-out
    [key: `ga-disable-${string}`]: boolean;
  }
}

const SCHEMA_VERSION = '3';

export interface GaBaseEvent {
  eventAction: string;
  eventCategory?: string;
  eventLabel?: string;
  eventValue?: number;
}

/**
 * All dimensions for GA metrics.
 *
 * The following two documents should also be updated when the dimensions is
 * updated.
 *
 * * Camera App PDD (Privacy Design Document): go/cca-metrics-pdd.
 * * CCA GA Events & Dimensions sheet: go/cca-metrics-schema.
 */
export enum GaMetricDimension {
  BOARD = 1,
  OS_VERSION = 2,
  // Obsolete 'sound' state.
  // SOUND = 3,
  MIRROR = 4,
  GRID = 5,
  TIMER = 6,
  MICROPHONE = 7,
  MAXIMIZED = 8,
  TALL_ORIENTATION = 9,
  RESOLUTION = 10,
  FPS = 11,
  INTENT_RESULT = 12,
  SHOULD_HANDLE_RESULT = 13,
  SHOULD_DOWN_SCALE = 14,
  IS_SECURE = 15,
  ERROR_NAME = 16,
  FILENAME = 17,
  FUNC_NAME = 18,
  LINE_NO = 19,
  COL_NO = 20,
  SHUTTER_TYPE = 21,
  IS_VIDEO_SNAPSHOT = 22,
  EVER_PAUSED = 23,
  SUPPORT_PAN = 24,
  SUPPORT_TILT = 25,
  SUPPORT_ZOOM = 26,
  // Obsolete
  // DOC_RESULT = 27,
  RECORD_TYPE = 28,
  GIF_RESULT = 29,
  DURATION = 30,
  SCHEMA_VERSION = 31,
  LAUNCH_TYPE = 32,
  DOC_FIX_TYPE = 33,
  RESOLUTION_LEVEL = 34,
  ASPECT_RATIO_SET = 35,
  DOC_PAGE_COUNT = 36,
  TIME_LAPSE_SPEED = 37,
  IS_TEST_IMAGE = 38,
  DEVICE_PIXEL_RATIO = 39,
  CAMERA_MODULE_ID = 40,
  WIFI_SECURITY_TYPE = 41,
  ZOOM_RATIO = 42,
  IS_PRIMARY_LANGUAGE = 43,
  LINE_COUNT = 44,
  WORD_COUNT = 45,
  PRESSURE = 46,
}

export enum Ga4MetricDimension {
  ASPECT_RATIO_SET = 'aspect_ratio_set',
  BOARD = 'board',
  BROWSER_VERSION = 'browser_version',
  CAMERA_MODULE_ID = 'camera_module_id',
  COL_NO = 'col_no',
  DEVICE_PIXEL_RATIO = 'device_pixel_ratio',
  DOC_FIX_TYPE = 'doc_fix_type',
  DOC_PAGE_COUNT = 'doc_page_count',
  DURATION = 'duration',
  ERROR_NAME = 'error_name',
  EVENT_CATEGORY = 'event_category',
  EVENT_LABEL = 'event_label',
  EVER_PAUSED = 'ever_paused',
  FILENAME = 'filename',
  FPS = 'fps',
  FUNC_NAME = 'func_name',
  GIF_RESULT = 'gif_result',
  GRID = 'grid',
  INTENT_RESULT = 'intent_result',
  IS_PRIMARY_LANGUAGE = 'is_primary_language',
  IS_SECURE = 'is_secure',
  IS_TEST_IMAGE = 'is_test_image',
  IS_VIDEO_SNAPSHOT = 'is_video_snapshot',
  LANGUAGE = 'language',
  LAUNCH_TYPE = 'launch_type',
  LINE_COUNT = 'line_count',
  LINE_NO = 'line_no',
  MAXIMIZED = 'maximized',
  MEMORY_USAGE = 'memory_usage',
  MICROPHONE = 'microphone',
  MIRROR = 'mirror',
  OS_VERSION = 'os_version',
  PRESSURE = 'pressure',
  RECORD_TYPE = 'record_type',
  RESOLUTION = 'resolution',
  RESOLUTION_LEVEL = 'resolution_level',
  SCHEMA_VERSION = 'schema_version',
  SCREEN_RESOLUTION = 'screen_resolution',
  SESSION_BEHAVIOR = 'session_behavior',
  SESSION_LENGTH = 'session_length',
  SHOULD_DOWN_SCALE = 'should_down_scale',
  SHOULD_HANDLE_RESULT = 'should_handle_result',
  SHUTTER_TYPE = 'shutter_type',
  SUPPORT_PAN = 'support_pan',
  SUPPORT_TILT = 'support_tilt',
  SUPPORT_ZOOM = 'support_zoom',
  TALL_ORIENTATION = 'tall_orientation',
  TIME_LAPSE_SPEED = 'time_lapse_speed',
  TIMER = 'timer',
  WIFI_SECURITY_TYPE = 'wifi_security_type',
  WORD_COUNT = 'word_count',
  ZOOM_RATIO = 'zoom_ratio',
}

export type Ga4EventParams =
    Partial<Record<Ga4MetricDimension, string>&{value: number}>;

type Ga4MeasurementProtocolEventParams = Ga4EventParams&{
  ['engagement_time_msec']: string,
  ['session_id']: string,
}

interface InitGaParams {
  id: string;
  baseDimensions: Map<GaMetricDimension, string>;
  clientId: string;
}
let gaBaseDimensions: InitGaParams['baseDimensions']|null = null;
/**
 * Initializes GA for sending metrics.
 *
 * @param initParams The parameters to initialize GA.
 * @param initParams.id The GA tracker ID to send events.
 * @param initParams.baseDimensions The base dimensions that will be sent in
 * every event.
 * @param initParams.clientId The client ID for the current client for GA.
 * @param setClientId The callback to store client id for GA.
 */
function initGa(
    initParams: InitGaParams, setClientId: (clientId: string) => void): void {
  // GA initialization function which is copied and inlined from
  // https://developers.google.com/analytics/devguides/collection/analyticsjs.
  window.GoogleAnalyticsObject = 'ga';
  // Creates an initial ga() function.
  // The queued commands will be executed once analytics.js loads.
  //
  // The type of .ga on Window doesn't include undefined, but since this part
  // is setup code for ga, it's possible to have a undefined case here. Disable
  // eslint which would think the condition is always true.
  //
  // The type assertion is also needed since this part of invariant is
  // maintained by ga itself, and all our usage only use the function call
  // interface.
  /* eslint-disable
       @typescript-eslint/strict-boolean-expressions,
       @typescript-eslint/consistent-type-assertions */
  window.ga = window.ga || ((...args: unknown[]) => {
                             (window.ga.q = window.ga.q || []).push(args);
                           }) as UniversalAnalytics.ga;
  /* eslint-enable
       @typescript-eslint/strict-boolean-expressions,
       @typescript-eslint/consistent-type-assertions */
  window.ga.l = Date.now();
  const a = document.createElement('script');
  const m = document.getElementsByTagName('script')[0];
  a.async = true;
  // TypeScript doesn't support setting .src to TrustedScriptURL yet.
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
  a.src = gaLibraryURL as unknown as string;
  assert(m.parentNode !== null);
  m.parentNode.insertBefore(a, m);

  const {id, baseDimensions, clientId} = initParams;
  gaBaseDimensions = baseDimensions;

  window.ga('create', id, {
    storage: 'none',
    clientId: clientId,
  });

  window.ga((tracker?: UniversalAnalytics.Tracker) => {
    assert(tracker !== undefined);
    setClientId(tracker.get('clientId'));
  });

  // By default GA uses a fake image and sets its source to the target URL to
  // record metrics. Since requesting remote image violates the policy of
  // a platform app, use navigator.sendBeacon() instead.
  window.ga('set', 'transport', 'beacon');

  // By default GA only accepts "http://" and "https://" protocol. Bypass the
  // check here since we are "chrome-extension://".
  window.ga('set', 'checkProtocolTask', null);

  // GA automatically derives geographical data from the IP address. Truncate
  // the IP address to avoid violating privacy policy.
  window.ga('set', 'anonymizeIp', true);
}

interface InitGa4Params {
  apiSecret: string;
  baseParams: Ga4EventParams;
  clientId?: string;
  measurementId: string;
  sessionId?: string;
}

type Ga4Config = Required<InitGa4Params>;

/**
 * `ga4Enabled` and `measurementProtocolUrl` are used in tests.
 */
let ga4Enabled = true;
let measurementProtocolUrl = 'https://www.google-analytics.com/mp/collect';
let ga4Config: Ga4Config|null = null;
/**
 * Initializes GA4 for sending metrics.
 *
 * @param initParams The parameters to initialize GA4.
 * @param initParams.apiSecret The API secret to send events via Measurement
 * Protocol.
 * @param initParams.baseParams The event parameters that will be sent in every
 * event.
 * @param initParams.clientId The client ID for the current client for GA4.
 * @param initParams.measurementId The GA4 measurement ID.
 * @param initParams.sessionId The session ID in every event.
 * @param setClientId The callback to store client id for GA4.
 */
function initGa4(
    initParams: InitGa4Params, setClientId: (clientId: string) => void): void {
  let {clientId, sessionId} = initParams;
  if (clientId === undefined || clientId === '') {
    clientId = generateGa4ClientId();
  }
  setClientId(clientId);
  if (sessionId === undefined || sessionId === '') {
    sessionId = generateGa4SessionId();
  }
  ga4Config = {
    ...initParams,
    clientId,
    sessionId,
  };
}

function generateGa4ClientId() {
  const randomNumber = Math.round(Math.random() * 0x7fffffff);
  const timestamp = Math.round(Date.now() / 1000);
  return `${randomNumber}.${timestamp}`;
}

function generateGa4SessionId() {
  return String(Date.now());
}

/**
 * Sends an "end_session" event when CCA is closed or refreshed.
 */
function registerGa4EndSessionEvent(): void {
  window.addEventListener('unload', () => {
    sendGa4Event({
      name: 'end_session',
      eventParams: {
        [Ga4MetricDimension.SESSION_LENGTH]: window.performance.now().toFixed(),
      },
      beacon: true,
    });
  });
}

export interface MemoryUsageEventDimension {
  memoryUsage: number;
  sessionBehavior: number;
}
let memoryEventDimensions: MemoryUsageEventDimension|null = null;

/**
 * Sends a "memory_usage" event when CCA is closed or refreshed.
 */
function registerGa4MemoryUsageEvent(): void {
  window.addEventListener('unload', () => {
    if (memoryEventDimensions !== null) {
      const {memoryUsage, sessionBehavior} = memoryEventDimensions;
      sendGa4Event({
        name: 'memory_usage',
        eventParams: {
          [Ga4MetricDimension.MEMORY_USAGE]: String(memoryUsage),
          [Ga4MetricDimension.SESSION_BEHAVIOR]: String(sessionBehavior),
        },
        beacon: true,
      });
    }
  });
}

/**
 * Updates the memory usage and session behavior value which will be sent at the
 * end of the session.
 *
 * @param updatedValue New updated dimensions value to be set.
 */
function updateMemoryUsageEventDimensions(
    updatedValue: MemoryUsageEventDimension): void {
  memoryEventDimensions = updatedValue;
}

interface SendGaEventParams {
  baseEvent: GaBaseEvent;
  dimensions: Map<GaMetricDimension, string>;
}

/**
 * Sends an event to GA.
 *
 * @param params The parameters object.
 * @param params.baseEvent The basic event properties.
 * @param params.dimensions Custom dimensions of the event.
 */
function sendGaEvent({baseEvent, dimensions}: SendGaEventParams): void {
  assert(gaBaseDimensions !== null);
  const event: UniversalAnalytics.FieldsObject = {...baseEvent};
  const mergedDimensions: Array<[GaMetricDimension, string]> = [
    ...gaBaseDimensions,
    ...dimensions,
    [GaMetricDimension.DEVICE_PIXEL_RATIO, getDevicePixelRatio()],
    [GaMetricDimension.SCHEMA_VERSION, SCHEMA_VERSION],
  ];
  for (const [key, value] of mergedDimensions) {
    event[`dimension${key}`] = value;
  }
  window.ga('send', 'event', event);
}

interface SendGa4EventParams {
  name: string;
  eventParams: Ga4EventParams;
  beacon?: boolean;
}

/**
 * Sends an event to GA4.
 *
 * @param params The parameters object.
 * @param params.name The name of the event.
 * @param params.eventParams The event parameters (custom dimensions) of the
 *     event.
 * @param params.beacon Send the event via `navigator.sendBeacon`.
 */
function sendGa4Event({
  name,
  eventParams,
  beacon = false,
}: SendGa4EventParams): void {
  if (!ga4Enabled) {
    return;
  }
  const {apiSecret, baseParams, clientId, measurementId, sessionId} =
      assertExists(ga4Config);
  const params: Ga4MeasurementProtocolEventParams = {
    ...baseParams,
    ...eventParams,
    [Ga4MetricDimension.DEVICE_PIXEL_RATIO]: getDevicePixelRatio(),
    [Ga4MetricDimension.LANGUAGE]: navigator.language,
    [Ga4MetricDimension.SCHEMA_VERSION]: SCHEMA_VERSION,
    [Ga4MetricDimension.SCREEN_RESOLUTION]: getScreenResolution(),
    // Set '1' here as it's enough for GA4 to generate the metrics for n-day
    // active users and we don't want to reimplement how gtag.js calculate the
    // engagement time for each event.
    // See
    // https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag#recommended_parameters_for_reports.
    ['engagement_time_msec']: '1',
    // Send this to create a session in GA4.
    // See
    // https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag#recommended_parameters_for_reports.
    ['session_id']: sessionId,
  };

  const url = `${measurementProtocolUrl}?measurement_id=${
      measurementId}&api_secret=${apiSecret}`;
  const body = JSON.stringify({
    ['client_id']: clientId,
    events: [{name, params}],
  });

  if (beacon) {
    navigator.sendBeacon(url, body);
  } else {
    void fetch(url, {method: 'POST', body});
  }
}

/**
 * Sets if GA can send metrics.
 *
 * @param id The GA tracker ID.
 * @param enabled True if the metrics is enabled.
 */
function setGaEnabled(id: string, enabled: boolean): void {
  window[`ga-disable-${id}`] = !enabled;
}

/**
 * Sets if GA4 can send metrics.
 *
 * @param enabled True if the metrics is enabled.
 */
function setGa4Enabled(enabled: boolean): void {
  ga4Enabled = enabled;
}

function getDevicePixelRatio() {
  return window.devicePixelRatio.toFixed(2);
}

function getScreenResolution() {
  const {width, height} = window.screen;
  return `${width}x${height}`;
}

/**
 * Change the URL that measurement protocol used to send events. This should
 * be only called in tests.
 */
function setMeasurementProtocolUrl(url: string): void {
  measurementProtocolUrl = url;
}

export interface GaHelper {
  initGa: typeof initGa;
  initGa4: typeof initGa4;
  registerGa4EndSessionEvent: typeof registerGa4EndSessionEvent;
  registerGa4MemoryUsageEvent: typeof registerGa4MemoryUsageEvent;
  sendGaEvent: typeof sendGaEvent;
  sendGa4Event: typeof sendGa4Event;
  setGaEnabled: typeof setGaEnabled;
  setGa4Enabled: typeof setGa4Enabled;
  setMeasurementProtocolUrl: typeof setMeasurementProtocolUrl;
  updateMemoryUsageEventDimensions: typeof updateMemoryUsageEventDimensions;
}
export {
  initGa,
  initGa4,
  registerGa4EndSessionEvent,
  registerGa4MemoryUsageEvent,
  sendGaEvent,
  sendGa4Event,
  setGaEnabled,
  setGa4Enabled,
  setMeasurementProtocolUrl,
  updateMemoryUsageEventDimensions,
};