| // Copyright (C) 2020 The Android Open Source Project | |
| // | |
| // Licensed under the Apache License, Version 2.0 (the "License"); | |
| // you may not use this file except in compliance with the License. | |
| // You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, software | |
| // distributed under the License is distributed on an "AS IS" BASIS, | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| // See the License for the specific language governing permissions and | |
| // limitations under the License. | |
| import type {ErrorDetails} from '../base/logging'; | |
| import {getCurrentChannel} from './channels'; | |
| import {VERSION} from '../virtual/version'; | |
| import {Router} from './router'; | |
| import type {Analytics, TraceCategories} from '../public/analytics'; | |
| const PAGE_TITLE = 'no-page-title'; | |
| function isValidUrl(s: string) { | |
| let url; | |
| try { | |
| url = new URL(s); | |
| } catch { | |
| return false; | |
| } | |
| return url.protocol === 'http:' || url.protocol === 'https:'; | |
| } | |
| function getReferrerOverride(): string | undefined { | |
| const route = Router.parseUrl(window.location.href); | |
| const referrer = route.args.referrer; | |
| if (referrer) { | |
| return referrer; | |
| } else { | |
| return undefined; | |
| } | |
| } | |
| // Get the referrer from either: | |
| // - If present: the referrer argument if present | |
| // - document.referrer | |
| function getReferrer(): string { | |
| const referrer = getReferrerOverride(); | |
| if (referrer) { | |
| if (isValidUrl(referrer)) { | |
| return referrer; | |
| } else { | |
| // Unclear if GA discards non-URL referrers. Lets try faking | |
| // a URL to test. | |
| const name = referrer.replaceAll('_', '-'); | |
| return `https://${name}.example.com/converted_non_url_referrer`; | |
| } | |
| } else { | |
| return document.referrer.split('?')[0]; | |
| } | |
| } | |
| // Interface exposed only to core (for the initialize method). | |
| export interface AnalyticsInternal extends Analytics { | |
| addDimension( | |
| arg: {key: string; value: string} | Promise<{key: string; value: string}>, | |
| ): void; | |
| initialize(): Promise<void>; | |
| } | |
| export function initAnalytics( | |
| testingMode: boolean, | |
| embeddedMode: boolean, | |
| enable: boolean, | |
| analyticsId: string | undefined, | |
| ): AnalyticsInternal { | |
| // Skip analytics if the fragment has "testing=1", this is used by UI tests. | |
| // Skip analytics in embeddedMode since iFrames do not have the same access to | |
| // local storage. | |
| // Skip analytics if the user has disabled analytics. | |
| // Skip analytics if the embedder does not provide an analytics ID. | |
| if (analyticsId !== undefined && !testingMode && !embeddedMode && enable) { | |
| return new AnalyticsImpl(analyticsId); | |
| } | |
| return new NullAnalytics(); | |
| } | |
| const gtagGlobals = window as {} as { | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| dataLayer: any[]; | |
| gtag: (command: string, event: string | Date, args?: {}) => void; | |
| }; | |
| class NullAnalytics implements AnalyticsInternal { | |
| addDimension( | |
| _arg: {key: string; value: string} | Promise<{key: string; value: string}>, | |
| ) {} | |
| async initialize() {} | |
| logEvent(_category: TraceCategories | null, _event: string) {} | |
| logError(_err: ErrorDetails) {} | |
| isEnabled(): boolean { | |
| return false; | |
| } | |
| } | |
| class AnalyticsImpl implements AnalyticsInternal { | |
| private initialized_ = false; | |
| private readonly analyticsId: string; | |
| private readonly dimensions: Array<Promise<{key: string; value: string}>> = | |
| []; | |
| constructor(analyticsId: string) { | |
| this.analyticsId = analyticsId; | |
| // The code below is taken from the official Google Analytics docs [1] and | |
| // adapted to TypeScript. We have it here rather than as an inline script | |
| // in index.html (as suggested by GA's docs) because inline scripts don't | |
| // play nicely with the CSP policy, at least in Firefox (Firefox doesn't | |
| // support all CSP 3 features we use). | |
| // [1] https://developers.google.com/analytics/devguides/collection/gtagjs . | |
| // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions | |
| gtagGlobals.dataLayer = gtagGlobals.dataLayer || []; | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| function gtagFunction(..._: any[]) { | |
| // This needs to be a function and not a lambda. |arguments| behaves | |
| // slightly differently in a lambda and breaks GA. | |
| gtagGlobals.dataLayer.push(arguments); | |
| } | |
| gtagGlobals.gtag = gtagFunction; | |
| gtagGlobals.gtag('js', new Date()); | |
| } | |
| addDimension( | |
| arg: {key: string; value: string} | Promise<{key: string; value: string}>, | |
| ) { | |
| if (this.initialized_) { | |
| throw new Error('Cannot add analytics dimensions after initialization'); | |
| } | |
| this.dimensions.push(Promise.resolve(arg)); | |
| } | |
| // It is fine to call updatePath() and log*() functions before initialize(). | |
| // The gtag() function internally enqueues all requests into |dataLayer|. | |
| async initialize() { | |
| if (this.initialized_) return; | |
| this.initialized_ = true; | |
| const script = document.createElement('script'); | |
| script.src = | |
| 'https://www.googletagmanager.com/gtag/js?id=' + this.analyticsId; | |
| script.defer = true; | |
| document.head.appendChild(script); | |
| const route = window.location.href; | |
| // Await all dimension promises and build a map of extra dimensions. | |
| const resolvedDimensions = await Promise.all(this.dimensions); | |
| const extraDimensions: Record<string, string> = {}; | |
| for (const {key, value} of resolvedDimensions) { | |
| extraDimensions[key] = value; | |
| } | |
| console.log(`GA initialized. route=${route}`); | |
| // GA's recommendation for SPAs is to disable automatic page views and | |
| // manually send page_view events. See: | |
| // https://developers.google.com/analytics/devguides/collection/gtagjs/pages#manual_pageviews | |
| gtagGlobals.gtag('config', this.analyticsId, { | |
| allow_google_signals: false, | |
| anonymize_ip: true, | |
| page_location: route, | |
| // Referrer as a URL including query string override. | |
| page_referrer: getReferrer(), | |
| send_page_view: false, | |
| page_title: PAGE_TITLE, | |
| perfetto_version: VERSION, | |
| // Release channel (canary, stable, autopush) | |
| perfetto_channel: getCurrentChannel(), | |
| // Referrer *if overridden* via the query string else empty string. | |
| perfetto_referrer_override: getReferrerOverride() ?? '', | |
| ...extraDimensions, | |
| }); | |
| gtagGlobals.gtag('event', 'page_view', { | |
| page_path: route, | |
| page_title: PAGE_TITLE, | |
| }); | |
| } | |
| logEvent(category: TraceCategories | null, event: string) { | |
| gtagGlobals.gtag('event', event, {event_category: category}); | |
| } | |
| logError(err: ErrorDetails) { | |
| let stack = ''; | |
| for (const entry of err.stack) { | |
| const shortLocation = entry.location.replace('frontend_bundle.js', '$'); | |
| stack += `${entry.name}(${shortLocation}),`; | |
| } | |
| // Strip trailing ',' (works also for empty strings without extra checks). | |
| stack = stack.substring(0, stack.length - 1); | |
| gtagGlobals.gtag('event', 'exception', { | |
| description: err.message, | |
| error_type: err.errType, | |
| // As per GA4 all field are restrictred to 100 chars. | |
| // page_title is the only one restricted to 1000 chars and we use that for | |
| // the full crash report. | |
| page_location: `http://crash?/${encodeURI(stack)}`, | |
| }); | |
| } | |
| isEnabled(): boolean { | |
| return true; | |
| } | |
| } |