Skip to content

Commit

Permalink
Merge pull request #5545 from NomicFoundation/feature/add-logic-to-se…
Browse files Browse the repository at this point in the history
…nd-telemetry-data

Add logic to send analytics data
  • Loading branch information
ChristopherDedominici authored Aug 20, 2024
2 parents c59a56d + 082b407 commit 2d4f8db
Show file tree
Hide file tree
Showing 10 changed files with 610 additions and 43 deletions.
15 changes: 15 additions & 0 deletions v-next/core/src/global-dir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ export async function getCacheDir(): Promise<string> {
return cache;
}

/**
* Returns the path to the telemetry directory for the specified package.
* If no package name is provided, the default package name "hardhat" is used.
* Ensures that the directory exists before returning the path.
*
* @param packageName - The name of the package to get the telemetry directory for. Defaults to "hardhat".
*
* @returns A promise that resolves to the path of the telemetry directory.
*/
export async function getTelemetryDir(packageName?: string): Promise<string> {
const { data } = await generatePaths(packageName);
await ensureDir(data);
return data;
}

async function generatePaths(packageName = "hardhat") {
const { default: envPaths } = await import("env-paths");
return envPaths(packageName);
Expand Down
133 changes: 133 additions & 0 deletions v-next/hardhat/src/internal/cli/telemetry/analytics/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type {
EventNames,
Payload,
TaskParams,
TelemetryConsentPayload,
} from "./types.js";

import os from "node:os";

import { spawnDetachedSubProcess } from "@ignored/hardhat-vnext-utils/subprocess";
import debug from "debug";

import { getHardhatVersion } from "../../../utils/package.js";
import {
isTelemetryAllowedInEnvironment,
isTelemetryAllowed,
} from "../telemetry-permissions.js";

import { getAnalyticsClientId } from "./utils.js";

const log = debug("hardhat:cli:telemetry:analytics");

const SESSION_ID = Math.random().toString();
const ENGAGEMENT_TIME_MSEC = "10000";

// Return a boolean for testing purposes to verify that analytics are not sent in CI environments
export async function sendTelemetryConsentAnalytics(
consent: boolean,
): Promise<boolean> {
// This is a special scenario where only the consent is sent, all the other analytics info
// (like node version, hardhat version, etc.) are stripped.

if (!isTelemetryAllowedInEnvironment()) {
return false;
}

const payload: TelemetryConsentPayload = {
client_id: "hardhat_telemetry_consent",
user_id: "hardhat_telemetry_consent",
user_properties: {},
events: [
{
name: "TelemetryConsentResponse",
params: {
userConsent: consent ? "yes" : "no",
},
},
],
};

await createSubprocessToSendAnalytics(payload);

return true;
}

export async function sendTaskAnalytics(taskId: string[]): Promise<boolean> {
const eventParams: TaskParams = {
task: taskId.join(", "),
};

return sendAnalytics("task", eventParams);
}

// Return a boolean for testing purposes to confirm whether analytics were sent based on the consent value and not in CI environments
async function sendAnalytics(
eventName: EventNames,
eventParams: TaskParams,
): Promise<boolean> {
if (!(await isTelemetryAllowed())) {
return false;
}

const payload = await buildPayload(eventName, eventParams);

await createSubprocessToSendAnalytics(payload);

return true;
}

async function createSubprocessToSendAnalytics(
payload: TelemetryConsentPayload | Payload,
): Promise<void> {
log(
`Sending analytics for '${payload.events[0].name}'. Payload: ${JSON.stringify(payload)}`,
);

// The HARDHAT_TEST_SUBPROCESS_RESULT_PATH env variable is used in the tests to instruct the subprocess to write the payload to a file
// instead of sending it.
// During testing, the subprocess file is a ts file, whereas in production, it is a js file (compiled code).
// The following lines adjust the file extension based on whether the environment is for testing or production.
const fileExt =
process.env.HARDHAT_TEST_SUBPROCESS_RESULT_PATH !== undefined ? "ts" : "js";
const subprocessFile = `${import.meta.dirname}/subprocess.${fileExt}`;

const env: Record<string, string> = {};
if (process.env.HARDHAT_TEST_SUBPROCESS_RESULT_PATH !== undefined) {
// ATTENTION: only for testing
env.HARDHAT_TEST_SUBPROCESS_RESULT_PATH =
process.env.HARDHAT_TEST_SUBPROCESS_RESULT_PATH;
}

await spawnDetachedSubProcess(subprocessFile, [JSON.stringify(payload)], env);

log("Payload sent to detached subprocess");
}

async function buildPayload(
eventName: EventNames,
eventParams: TaskParams,
): Promise<Payload> {
const clientId = await getAnalyticsClientId();

return {
client_id: clientId,
user_id: clientId,
user_properties: {
projectId: { value: "hardhat-project" },
hardhatVersion: { value: await getHardhatVersion() },
operatingSystem: { value: os.platform() },
nodeVersion: { value: process.version },
},
events: [
{
name: eventName,
params: {
engagement_time_msec: ENGAGEMENT_TIME_MSEC,
session_id: SESSION_ID,
...eventParams,
},
},
],
};
}
22 changes: 22 additions & 0 deletions v-next/hardhat/src/internal/cli/telemetry/analytics/subprocess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { writeJsonFile } from "@ignored/hardhat-vnext-utils/fs";
import { postJsonRequest } from "@ignored/hardhat-vnext-utils/request";

// These keys are expected to be public
// TODO: replace with prod values
const ANALYTICS_URL = "https://www.google-analytics.com/mp/collect";
const API_SECRET = "iXzTRik5RhahYpgiatSv1w";
const MEASUREMENT_ID = "G-ZFZWHGZ64H";

const payload = JSON.parse(process.argv[2]);

if (process.env.HARDHAT_TEST_SUBPROCESS_RESULT_PATH === undefined) {
await postJsonRequest(ANALYTICS_URL, payload, {
queryParams: {
api_secret: API_SECRET,
measurement_id: MEASUREMENT_ID,
},
});
} else {
// ATTENTION: only for testing
await writeJsonFile(process.env.HARDHAT_TEST_SUBPROCESS_RESULT_PATH, payload);
}
63 changes: 63 additions & 0 deletions v-next/hardhat/src/internal/cli/telemetry/analytics/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
export interface AnalyticsFile {
analytics: {
clientId: string;
};
}

/* eslint-disable @typescript-eslint/naming-convention -- these payload is formatted based on what google analytics expects*/
export interface BasePayload {
client_id: string;
user_id: string;
user_properties: {};
events: Array<{
name: string;
params: {
// From the GA docs: amount of time someone spends with your web
// page in focus or app screen in the foreground.
// The parameter has no use for our app, but it's required in order
// for user activity to display in standard reports like Realtime.
engagement_time_msec?: string;
session_id?: string;
};
}>;
}

export interface TelemetryConsentPayload extends BasePayload {
events: Array<{
name: "TelemetryConsentResponse";
params: {
userConsent: "yes" | "no";
session_id?: string;
};
}>;
}

export type EventNames = "task";

export interface TaskParams {
task: string;
}

export interface Payload extends BasePayload {
user_properties: {
projectId: {
value: string;
};
hardhatVersion: {
value: string;
};
operatingSystem: {
value: string;
};
nodeVersion: {
value: string;
};
};
events: Array<{
name: EventNames;
params: {
engagement_time_msec: string;
session_id: string;
} & TaskParams;
}>;
}
58 changes: 58 additions & 0 deletions v-next/hardhat/src/internal/cli/telemetry/analytics/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { AnalyticsFile } from "./types.js";

import path from "node:path";

import { getTelemetryDir } from "@ignored/hardhat-vnext-core/global-dir";
import {
exists,
readJsonFile,
writeJsonFile,
} from "@ignored/hardhat-vnext-utils/fs";
import debug from "debug";

const log = debug("hardhat:cli:telemetry:analytics:utils");

const ANALYTICS_FILE_NAME = "analytics.json";

export async function getAnalyticsClientId(): Promise<string> {
let clientId = await readAnalyticsClientId();

if (clientId === undefined) {
log("Client Id not found, generating a new one");

clientId = crypto.randomUUID();
await writeAnalyticsClientId(clientId);
}

return clientId;
}

async function readAnalyticsClientId(): Promise<string | undefined> {
const globalTelemetryDir = await getTelemetryDir();
const filePath = path.join(globalTelemetryDir, ANALYTICS_FILE_NAME);

log(`Looking up Client Id at '${filePath}'`);

if ((await exists(filePath)) === false) {
return undefined;
}

const data: AnalyticsFile = await readJsonFile(filePath);
const clientId = data.analytics.clientId;

log(`Client Id found: ${clientId}`);

return clientId;
}

async function writeAnalyticsClientId(clientId: string): Promise<void> {
const globalTelemetryDir = await getTelemetryDir();
const filePath = path.join(globalTelemetryDir, ANALYTICS_FILE_NAME);
await writeJsonFile(filePath, {
analytics: {
clientId,
},
});

log(`Stored clientId '${clientId}'`);
}
Loading

0 comments on commit 2d4f8db

Please sign in to comment.