blob: 3e486ed9c0a2261a819bd932f16754a13a9f0422 [file]
// 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
};