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