• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2020 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import {ErrorDetails} from '../base/logging';
16import {getCurrentChannel} from '../common/channels';
17import {VERSION} from '../gen/perfetto_version';
18
19import {globals} from './globals';
20import {Router} from './router';
21
22type TraceCategories = 'Trace Actions' | 'Record Trace' | 'User Actions';
23const ANALYTICS_ID = 'G-BD89KT2P3C';
24const PAGE_TITLE = 'no-page-title';
25
26function isValidUrl(s: string) {
27  let url;
28  try {
29    url = new URL(s);
30  } catch (_) {
31    return false;
32  }
33  return url.protocol === 'http:' || url.protocol === 'https:';
34}
35
36function getReferrerOverride(): string | undefined {
37  const route = Router.parseUrl(window.location.href);
38  const referrer = route.args.referrer;
39  if (referrer) {
40    return referrer;
41  } else {
42    return undefined;
43  }
44}
45
46// Get the referrer from either:
47// - If present: the referrer argument if present
48// - document.referrer
49function getReferrer(): string {
50  const referrer = getReferrerOverride();
51  if (referrer) {
52    if (isValidUrl(referrer)) {
53      return referrer;
54    } else {
55      // Unclear if GA discards non-URL referrers. Lets try faking
56      // a URL to test.
57      const name = referrer.replaceAll('_', '-');
58      return `https://${name}.example.com/converted_non_url_referrer`;
59    }
60  } else {
61    return document.referrer.split('?')[0];
62  }
63}
64
65export function initAnalytics() {
66  // Only initialize logging on the official site and on localhost (to catch
67  // analytics bugs when testing locally).
68  // Skip analytics is the fragment has "testing=1", this is used by UI tests.
69  // Skip analytics in embeddedMode since iFrames do not have the same access to
70  // local storage.
71  if (
72    (window.location.origin.startsWith('http://localhost:') ||
73      window.location.origin.endsWith('.perfetto.dev')) &&
74    !globals.testing &&
75    !globals.embeddedMode
76  ) {
77    return new AnalyticsImpl();
78  }
79  return new NullAnalytics();
80}
81
82const gtagGlobals = window as {} as {
83  // eslint-disable-next-line @typescript-eslint/no-explicit-any
84  dataLayer: any[];
85  gtag: (command: string, event: string | Date, args?: {}) => void;
86};
87
88export interface Analytics {
89  initialize(): void;
90  updatePath(_: string): void;
91  logEvent(category: TraceCategories | null, event: string): void;
92  logError(err: ErrorDetails): void;
93  isEnabled(): boolean;
94}
95
96export class NullAnalytics implements Analytics {
97  initialize() {}
98  updatePath(_: string) {}
99  logEvent(_category: TraceCategories | null, _event: string) {}
100  logError(_err: ErrorDetails) {}
101  isEnabled(): boolean {
102    return false;
103  }
104}
105
106class AnalyticsImpl implements Analytics {
107  private initialized_ = false;
108
109  constructor() {
110    // The code below is taken from the official Google Analytics docs [1] and
111    // adapted to TypeScript. We have it here rather than as an inline script
112    // in index.html (as suggested by GA's docs) because inline scripts don't
113    // play nicely with the CSP policy, at least in Firefox (Firefox doesn't
114    // support all CSP 3 features we use).
115    // [1] https://developers.google.com/analytics/devguides/collection/gtagjs .
116    // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
117    gtagGlobals.dataLayer = gtagGlobals.dataLayer || [];
118
119    // eslint-disable-next-line @typescript-eslint/no-explicit-any
120    function gtagFunction(..._: any[]) {
121      // This needs to be a function and not a lambda. |arguments| behaves
122      // slightly differently in a lambda and breaks GA.
123      gtagGlobals.dataLayer.push(arguments);
124    }
125    gtagGlobals.gtag = gtagFunction;
126    gtagGlobals.gtag('js', new Date());
127  }
128
129  // This is callled only after the script that sets isInternalUser loads.
130  // It is fine to call updatePath() and log*() functions before initialize().
131  // The gtag() function internally enqueues all requests into |dataLayer|.
132  initialize() {
133    if (this.initialized_) return;
134    this.initialized_ = true;
135    const script = document.createElement('script');
136    script.src = 'https://www.googletagmanager.com/gtag/js?id=' + ANALYTICS_ID;
137    script.defer = true;
138    document.head.appendChild(script);
139    const route = window.location.href;
140    console.log(
141      `GA initialized. route=${route}`,
142      `isInternalUser=${globals.isInternalUser}`,
143    );
144    // GA's recommendation for SPAs is to disable automatic page views and
145    // manually send page_view events. See:
146    // https://developers.google.com/analytics/devguides/collection/gtagjs/pages#manual_pageviews
147    gtagGlobals.gtag('config', ANALYTICS_ID, {
148      allow_google_signals: false,
149      anonymize_ip: true,
150      page_location: route,
151      // Referrer as a URL including query string override.
152      page_referrer: getReferrer(),
153      send_page_view: false,
154      page_title: PAGE_TITLE,
155      perfetto_is_internal_user: globals.isInternalUser ? '1' : '0',
156      perfetto_version: VERSION,
157      // Release channel (canary, stable, autopush)
158      perfetto_channel: getCurrentChannel(),
159      // Referrer *if overridden* via the query string else empty string.
160      perfetto_referrer_override: getReferrerOverride() ?? '',
161    });
162    this.updatePath(route);
163  }
164
165  updatePath(path: string) {
166    gtagGlobals.gtag('event', 'page_view', {
167      page_path: path,
168      page_title: PAGE_TITLE,
169    });
170  }
171
172  logEvent(category: TraceCategories | null, event: string) {
173    gtagGlobals.gtag('event', event, {event_category: category});
174  }
175
176  logError(err: ErrorDetails) {
177    let stack = '';
178    for (const entry of err.stack) {
179      const shortLocation = entry.location.replace('frontend_bundle.js', '$');
180      stack += `${entry.name}(${shortLocation}),`;
181    }
182    // Strip trailing ',' (works also for empty strings without extra checks).
183    stack = stack.substring(0, stack.length - 1);
184
185    gtagGlobals.gtag('event', 'exception', {
186      description: err.message,
187      error_type: err.errType,
188
189      // As per GA4 all field are restrictred to 100 chars.
190      // page_title is the only one restricted to 1000 chars and we use that for
191      // the full crash report.
192      page_location: `http://crash?/${encodeURI(stack)}`,
193    });
194  }
195
196  isEnabled(): boolean {
197    return true;
198  }
199}
200