| // Copyright 2022 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| // Exception: valid use of `!=` to cover both `null` and `undefined`. |
| /* eslint-disable eqeqeq */ |
| |
| import * as https from 'https'; |
| import * as querystring from 'querystring'; |
| import * as vscode from 'vscode'; |
| |
| import { isDisabledByEnvironment, isRunByBot } from './environment_status'; |
| import * as messages from './messages'; |
| import { EnabledPropertyWatcher, PersistentStatus } from './persistent_status'; |
| |
| // Set to true for analytics to be sent to the debug endpoint (non-logging) for validation. |
| const DEBUG = false; |
| |
| // Set to true when developing/testing the analytics library itself. |
| const SEND_ANALYTICS_IN_DEV_OR_TEST = false; |
| |
| const GA_TRACKING_ID = 'UA-127897021-18'; |
| |
| type Category = 'ffx-invocation'; |
| |
| export interface Event { |
| category: Category; |
| action: string; |
| label?: string; |
| value?: number; |
| } |
| |
| class EventData { |
| [key: string]: string | number; |
| readonly t: string = 'event'; |
| |
| constructor(event: Event) { |
| this.ec = event.category; |
| this.ea = event.action; |
| if (event.label != null) { |
| this.el = event.label; |
| } |
| if (event.value != null) { |
| this.ev = event.value; |
| } |
| } |
| } |
| |
| export interface Timing { |
| category: Category; |
| variable: string; |
| time: number; |
| label?: string; |
| pageLoadTime?: number; |
| dnsTime?: number; |
| pageDownloadTime?: number; |
| redirectResponseTime?: number; |
| tcpConnectTime?: number; |
| serverResponseTime?: number; |
| domInteractiveTime?: number; |
| contentLoadTime?: number; |
| } |
| |
| class TimingData { |
| [key: string]: string | number; |
| readonly t = 'timing'; |
| |
| constructor(timing: Timing) { |
| this.utc = timing.category; |
| this.utv = timing.variable; |
| this.utt = Math.round(timing.time); |
| if (timing.label != null) { |
| this.utl = timing.label; |
| } |
| if (timing.pageLoadTime != null) { |
| this.plt = Math.round(timing.pageLoadTime); |
| } |
| if (timing.dnsTime != null) { |
| this.dns = Math.round(timing.dnsTime); |
| } |
| if (timing.pageDownloadTime != null) { |
| this.pdt = Math.round(timing.pageDownloadTime); |
| } |
| if (timing.redirectResponseTime != null) { |
| this.rrt = Math.round(timing.redirectResponseTime); |
| } |
| if (timing.tcpConnectTime != null) { |
| this.tcp = Math.round(timing.tcpConnectTime); |
| } |
| if (timing.serverResponseTime != null) { |
| this.srt = Math.round(timing.serverResponseTime); |
| } |
| if (timing.domInteractiveTime != null) { |
| this.dit = Math.round(timing.domInteractiveTime); |
| } |
| if (timing.contentLoadTime != null) { |
| this.clt = Math.round(timing.contentLoadTime); |
| } |
| } |
| } |
| |
| export interface Exception { |
| description?: string; |
| isFatal?: boolean; |
| } |
| |
| class ExceptionData { |
| [key: string]: string | number; |
| readonly t = 'exception'; |
| |
| constructor(exception: Exception) { |
| if (exception.description != null) { |
| this.exd = exception.description.trim(); |
| } |
| if (exception.isFatal != null) { |
| this.exf = exception.isFatal ? 1 : 0; |
| } |
| } |
| } |
| |
| export interface GeneralParameters { |
| applicationName?: string; |
| applicationVersion?: string; |
| customMetrics?: { [key: number]: number }; |
| customDimensions?: { [key: number]: string }; |
| } |
| |
| class GeneralParametersData { |
| [key: string]: string | number; |
| |
| constructor(parameters: GeneralParameters) { |
| if (parameters.applicationName != null) { |
| this.an = parameters.applicationName; |
| } |
| if (parameters.applicationVersion != null) { |
| this.av = parameters.applicationVersion; |
| } |
| if (parameters.customMetrics != null) { |
| for (const [index, value] of Object.entries(parameters.customMetrics)) { |
| this['cm' + index] = value; |
| } |
| } |
| if (parameters.customDimensions != null) { |
| for (const [index, value] of Object.entries(parameters.customDimensions)) { |
| this['cd' + index] = value; |
| } |
| } |
| } |
| } |
| |
| |
| export class Analytics { |
| private static _instance: Analytics; |
| private sharedParametersData: GeneralParametersData = {}; |
| private disableAnalyticsForSession = false; |
| private uuid: string | undefined = undefined; |
| private isRunInProductionMode: boolean = true; |
| |
| private constructor() { } |
| |
| static get instance() { |
| if (!Analytics._instance) { |
| Analytics._instance = new Analytics(); |
| } |
| |
| return Analytics._instance; |
| } |
| |
| setSharedParameters(parameters: GeneralParameters): void { |
| this.sharedParametersData = new GeneralParametersData(parameters); |
| } |
| |
| updateSharedParameters(parameters: GeneralParameters): void { |
| this.sharedParametersData = { |
| ...this.sharedParametersData, |
| ...(new GeneralParametersData(parameters)) |
| }; |
| } |
| |
| addEvent(event: Event, other?: GeneralParameters): Promise<void> { |
| return this.send( |
| { ...(new EventData(event)), ...(other ? new GeneralParametersData(other) : {}) }); |
| } |
| |
| addTiming(timing: Timing, other?: GeneralParameters): Promise<void> { |
| return this.send( |
| { ...(new TimingData(timing)), ...(other ? new GeneralParametersData(other) : {}) }); |
| } |
| |
| addException(exception: Exception, other?: GeneralParameters): Promise<void> { |
| return this.send( |
| { ...(new ExceptionData(exception)), ...(other ? new GeneralParametersData(other) : {}) }); |
| } |
| |
| // The send method is implemented based on the same named method defined in |
| // https://github.com/Dart-Code/Dart-Code/blob/c85490fdc8/src/extension/analytics.ts |
| private async send(customData: { [key: string]: string | number }): Promise<void> { |
| if (this.disableAnalyticsForSession || !vscode.env.isTelemetryEnabled || |
| this.uuid === undefined) { |
| return; |
| } |
| |
| const data = { |
| cid: this.uuid, |
| tid: GA_TRACKING_ID, |
| v: '1', // API Version. |
| ...this.sharedParametersData, |
| ...customData |
| }; |
| |
| if (DEBUG) { |
| console.log('Sending analytic: ' + JSON.stringify(data)); |
| } |
| |
| const options: https.RequestOptions = { |
| headers: { |
| // eslint-disable-next-line @typescript-eslint/naming-convention |
| 'Content-Type': 'application/x-www-form-urlencoded', |
| }, |
| hostname: 'www.google-analytics.com', |
| method: 'POST', |
| path: DEBUG ? '/debug/collect' : '/collect', |
| port: 443, |
| }; |
| |
| await new Promise<void>((resolve) => { |
| try { |
| const req = https.request(options, (resp) => { |
| if (DEBUG) { |
| resp.on('data', (c: Buffer | string) => { |
| try { |
| const gaDebugResp = JSON.parse(c.toString()); |
| if (gaDebugResp && gaDebugResp.hitParsingResult && |
| gaDebugResp.hitParsingResult[0].valid === true) { |
| console.log('Sent OK!'); |
| } else if ( |
| gaDebugResp && gaDebugResp.hitParsingResult && |
| gaDebugResp.hitParsingResult[0].valid === false) { |
| console.log(`Invalid hit: ${c?.toString()}`); |
| } else { |
| console.log(`Unexpected GA debug response: ${c?.toString()}`); |
| } |
| } catch (e) { |
| console.log(`Error in GA debug response: ${c?.toString()}`); |
| } |
| }); |
| } |
| |
| if (!resp || !resp.statusCode || resp.statusCode < 200 || resp.statusCode > 300) { |
| console.log( |
| `Failed to send analytics ${resp && resp.statusCode}: ${resp && resp.statusMessage}`); |
| } |
| resolve(); |
| }); |
| req.write(querystring.stringify(data)); |
| req.on('error', (e) => { |
| this.handleError(e); |
| resolve(); |
| }); |
| req.end(); |
| } catch (e) { |
| this.handleError(e); |
| resolve(); |
| } |
| }); |
| } |
| |
| private handleError(e: any) { |
| console.log(`Failed to send analytics, disabling for session: ${e}`); |
| this.disableAnalyticsForSession = true; |
| } |
| |
| // Starting below are methods specific to this project |
| |
| private disposables: vscode.Disposable[] = []; |
| private watcher: EnabledPropertyWatcher | undefined; |
| |
| registerDisposable(disposable: vscode.Disposable) { |
| this.disposables.push(disposable); |
| } |
| |
| disposeAll() { |
| while (this.disposables.length > 0) { |
| const disposable = this.disposables.pop(); |
| disposable?.dispose(); |
| } |
| this.watcher = undefined; |
| } |
| |
| async init(): Promise<void> { |
| if (isDisabledByEnvironment() || isRunByBot() || |
| (!this.isRunInProductionMode && !SEND_ANALYTICS_IN_DEV_OR_TEST)) { |
| this.disableAnalyticsForSession = true; |
| return; |
| } |
| const persistentStatus = new PersistentStatus('vscode-fuchsia'); |
| if (await persistentStatus.isFirstLaunchOfFirstTool()) { |
| await this.initFirstRunOfFirstTool(persistentStatus); |
| } else if (await persistentStatus.isFirstDirectLaunch()) { |
| await this.initFirstRunOfOtherTool(persistentStatus); |
| } else { |
| await this.initSubsequentRun(persistentStatus); |
| } |
| this.registerDisposable(vscode.env.onDidChangeTelemetryEnabled(async (enabled) => { |
| if (enabled && await persistentStatus.isEnabled()) { |
| this.uuid = await persistentStatus.getUuid(); |
| this.disableAnalyticsForSession = false; |
| } else { |
| this.uuid = undefined; |
| this.disableAnalyticsForSession = true; |
| } |
| })); |
| this.watcher = new EnabledPropertyWatcher(); |
| this.registerDisposable(this.watcher); |
| this.registerDisposable(persistentStatus.onDidChangeEnabled(this.watcher, async (enabled) => { |
| if (enabled && vscode.env.isTelemetryEnabled) { |
| this.uuid = await persistentStatus.getUuid(); |
| this.disableAnalyticsForSession = false; |
| } else { |
| this.uuid = undefined; |
| this.disableAnalyticsForSession = true; |
| } |
| })); |
| } |
| |
| setExtensionInfo(context: vscode.ExtensionContext): void { |
| this.isRunInProductionMode = (context.extensionMode === vscode.ExtensionMode.Production); |
| const packageJson = context.extension.packageJSON; |
| this.updateSharedParameters( |
| { 'applicationName': packageJson.name, 'applicationVersion': packageJson.version }); |
| } |
| |
| private showMessage(message: string): void { |
| void vscode.window.showInformationMessage(message, { modal: true }); |
| } |
| |
| private async initFirstRunOfFirstTool(persistentStatus: PersistentStatus): Promise<void> { |
| if (vscode.env.isTelemetryEnabled) { |
| this.showMessage(messages.MESSAGE_FIRST_RUN_FIRST_TOOL_TELEMETRY_ON); |
| } else { |
| this.showMessage(messages.MESSAGE_FIRST_RUN_FIRST_TOOL_TELEMETRY_OFF); |
| } |
| |
| await persistentStatus.enable(); |
| await persistentStatus.markAsDirectlyLaunched(); |
| // Analytics is not collected on the very first run |
| this.disableAnalyticsForSession = true; |
| } |
| |
| private async initFirstRunOfOtherTool(persistentStatus: PersistentStatus): Promise<void> { |
| if (await persistentStatus.isEnabled()) { |
| if (vscode.env.isTelemetryEnabled) { |
| this.showMessage(messages.MESSAGE_FIRST_RUN_OTHER_TOOL_ENABLED_TELEMETRY_ON); |
| this.uuid = await persistentStatus.getUuid(); |
| } else { |
| this.showMessage(messages.MESSAGE_FIRST_RUN_OTHER_TOOL_ENABLED_TELEMETRY_OFF); |
| this.disableAnalyticsForSession = true; |
| } |
| } else { |
| this.showMessage(messages.MESSAGE_FIRST_RUN_OTHER_TOOL_DISABLED); |
| this.disableAnalyticsForSession = true; |
| } |
| await persistentStatus.markAsDirectlyLaunched(); |
| } |
| |
| private async initSubsequentRun(persistentStatus: PersistentStatus): Promise<void> { |
| if (await persistentStatus.isEnabled() && vscode.env.isTelemetryEnabled) { |
| this.uuid = await persistentStatus.getUuid(); |
| } else { |
| this.disableAnalyticsForSession = true; |
| } |
| } |
| } |
| |
| |
| // exporting classes with PascalCase for testing |
| /* eslint-disable @typescript-eslint/naming-convention */ |
| export const TEST_ONLY = { |
| EventData, |
| TimingData, |
| ExceptionData, |
| GeneralParametersData |
| }; |