Skip to content

Commit 2d4f8db

Browse files
Merge pull request #5545 from NomicFoundation/feature/add-logic-to-send-telemetry-data
Add logic to send analytics data
2 parents c59a56d + 082b407 commit 2d4f8db

File tree

10 files changed

+610
-43
lines changed

10 files changed

+610
-43
lines changed

v-next/core/src/global-dir.ts

+15
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,21 @@ export async function getCacheDir(): Promise<string> {
2222
return cache;
2323
}
2424

25+
/**
26+
* Returns the path to the telemetry directory for the specified package.
27+
* If no package name is provided, the default package name "hardhat" is used.
28+
* Ensures that the directory exists before returning the path.
29+
*
30+
* @param packageName - The name of the package to get the telemetry directory for. Defaults to "hardhat".
31+
*
32+
* @returns A promise that resolves to the path of the telemetry directory.
33+
*/
34+
export async function getTelemetryDir(packageName?: string): Promise<string> {
35+
const { data } = await generatePaths(packageName);
36+
await ensureDir(data);
37+
return data;
38+
}
39+
2540
async function generatePaths(packageName = "hardhat") {
2641
const { default: envPaths } = await import("env-paths");
2742
return envPaths(packageName);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import type {
2+
EventNames,
3+
Payload,
4+
TaskParams,
5+
TelemetryConsentPayload,
6+
} from "./types.js";
7+
8+
import os from "node:os";
9+
10+
import { spawnDetachedSubProcess } from "@ignored/hardhat-vnext-utils/subprocess";
11+
import debug from "debug";
12+
13+
import { getHardhatVersion } from "../../../utils/package.js";
14+
import {
15+
isTelemetryAllowedInEnvironment,
16+
isTelemetryAllowed,
17+
} from "../telemetry-permissions.js";
18+
19+
import { getAnalyticsClientId } from "./utils.js";
20+
21+
const log = debug("hardhat:cli:telemetry:analytics");
22+
23+
const SESSION_ID = Math.random().toString();
24+
const ENGAGEMENT_TIME_MSEC = "10000";
25+
26+
// Return a boolean for testing purposes to verify that analytics are not sent in CI environments
27+
export async function sendTelemetryConsentAnalytics(
28+
consent: boolean,
29+
): Promise<boolean> {
30+
// This is a special scenario where only the consent is sent, all the other analytics info
31+
// (like node version, hardhat version, etc.) are stripped.
32+
33+
if (!isTelemetryAllowedInEnvironment()) {
34+
return false;
35+
}
36+
37+
const payload: TelemetryConsentPayload = {
38+
client_id: "hardhat_telemetry_consent",
39+
user_id: "hardhat_telemetry_consent",
40+
user_properties: {},
41+
events: [
42+
{
43+
name: "TelemetryConsentResponse",
44+
params: {
45+
userConsent: consent ? "yes" : "no",
46+
},
47+
},
48+
],
49+
};
50+
51+
await createSubprocessToSendAnalytics(payload);
52+
53+
return true;
54+
}
55+
56+
export async function sendTaskAnalytics(taskId: string[]): Promise<boolean> {
57+
const eventParams: TaskParams = {
58+
task: taskId.join(", "),
59+
};
60+
61+
return sendAnalytics("task", eventParams);
62+
}
63+
64+
// Return a boolean for testing purposes to confirm whether analytics were sent based on the consent value and not in CI environments
65+
async function sendAnalytics(
66+
eventName: EventNames,
67+
eventParams: TaskParams,
68+
): Promise<boolean> {
69+
if (!(await isTelemetryAllowed())) {
70+
return false;
71+
}
72+
73+
const payload = await buildPayload(eventName, eventParams);
74+
75+
await createSubprocessToSendAnalytics(payload);
76+
77+
return true;
78+
}
79+
80+
async function createSubprocessToSendAnalytics(
81+
payload: TelemetryConsentPayload | Payload,
82+
): Promise<void> {
83+
log(
84+
`Sending analytics for '${payload.events[0].name}'. Payload: ${JSON.stringify(payload)}`,
85+
);
86+
87+
// The HARDHAT_TEST_SUBPROCESS_RESULT_PATH env variable is used in the tests to instruct the subprocess to write the payload to a file
88+
// instead of sending it.
89+
// During testing, the subprocess file is a ts file, whereas in production, it is a js file (compiled code).
90+
// The following lines adjust the file extension based on whether the environment is for testing or production.
91+
const fileExt =
92+
process.env.HARDHAT_TEST_SUBPROCESS_RESULT_PATH !== undefined ? "ts" : "js";
93+
const subprocessFile = `${import.meta.dirname}/subprocess.${fileExt}`;
94+
95+
const env: Record<string, string> = {};
96+
if (process.env.HARDHAT_TEST_SUBPROCESS_RESULT_PATH !== undefined) {
97+
// ATTENTION: only for testing
98+
env.HARDHAT_TEST_SUBPROCESS_RESULT_PATH =
99+
process.env.HARDHAT_TEST_SUBPROCESS_RESULT_PATH;
100+
}
101+
102+
await spawnDetachedSubProcess(subprocessFile, [JSON.stringify(payload)], env);
103+
104+
log("Payload sent to detached subprocess");
105+
}
106+
107+
async function buildPayload(
108+
eventName: EventNames,
109+
eventParams: TaskParams,
110+
): Promise<Payload> {
111+
const clientId = await getAnalyticsClientId();
112+
113+
return {
114+
client_id: clientId,
115+
user_id: clientId,
116+
user_properties: {
117+
projectId: { value: "hardhat-project" },
118+
hardhatVersion: { value: await getHardhatVersion() },
119+
operatingSystem: { value: os.platform() },
120+
nodeVersion: { value: process.version },
121+
},
122+
events: [
123+
{
124+
name: eventName,
125+
params: {
126+
engagement_time_msec: ENGAGEMENT_TIME_MSEC,
127+
session_id: SESSION_ID,
128+
...eventParams,
129+
},
130+
},
131+
],
132+
};
133+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { writeJsonFile } from "@ignored/hardhat-vnext-utils/fs";
2+
import { postJsonRequest } from "@ignored/hardhat-vnext-utils/request";
3+
4+
// These keys are expected to be public
5+
// TODO: replace with prod values
6+
const ANALYTICS_URL = "https://www.google-analytics.com/mp/collect";
7+
const API_SECRET = "iXzTRik5RhahYpgiatSv1w";
8+
const MEASUREMENT_ID = "G-ZFZWHGZ64H";
9+
10+
const payload = JSON.parse(process.argv[2]);
11+
12+
if (process.env.HARDHAT_TEST_SUBPROCESS_RESULT_PATH === undefined) {
13+
await postJsonRequest(ANALYTICS_URL, payload, {
14+
queryParams: {
15+
api_secret: API_SECRET,
16+
measurement_id: MEASUREMENT_ID,
17+
},
18+
});
19+
} else {
20+
// ATTENTION: only for testing
21+
await writeJsonFile(process.env.HARDHAT_TEST_SUBPROCESS_RESULT_PATH, payload);
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
export interface AnalyticsFile {
2+
analytics: {
3+
clientId: string;
4+
};
5+
}
6+
7+
/* eslint-disable @typescript-eslint/naming-convention -- these payload is formatted based on what google analytics expects*/
8+
export interface BasePayload {
9+
client_id: string;
10+
user_id: string;
11+
user_properties: {};
12+
events: Array<{
13+
name: string;
14+
params: {
15+
// From the GA docs: amount of time someone spends with your web
16+
// page in focus or app screen in the foreground.
17+
// The parameter has no use for our app, but it's required in order
18+
// for user activity to display in standard reports like Realtime.
19+
engagement_time_msec?: string;
20+
session_id?: string;
21+
};
22+
}>;
23+
}
24+
25+
export interface TelemetryConsentPayload extends BasePayload {
26+
events: Array<{
27+
name: "TelemetryConsentResponse";
28+
params: {
29+
userConsent: "yes" | "no";
30+
session_id?: string;
31+
};
32+
}>;
33+
}
34+
35+
export type EventNames = "task";
36+
37+
export interface TaskParams {
38+
task: string;
39+
}
40+
41+
export interface Payload extends BasePayload {
42+
user_properties: {
43+
projectId: {
44+
value: string;
45+
};
46+
hardhatVersion: {
47+
value: string;
48+
};
49+
operatingSystem: {
50+
value: string;
51+
};
52+
nodeVersion: {
53+
value: string;
54+
};
55+
};
56+
events: Array<{
57+
name: EventNames;
58+
params: {
59+
engagement_time_msec: string;
60+
session_id: string;
61+
} & TaskParams;
62+
}>;
63+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { AnalyticsFile } from "./types.js";
2+
3+
import path from "node:path";
4+
5+
import { getTelemetryDir } from "@ignored/hardhat-vnext-core/global-dir";
6+
import {
7+
exists,
8+
readJsonFile,
9+
writeJsonFile,
10+
} from "@ignored/hardhat-vnext-utils/fs";
11+
import debug from "debug";
12+
13+
const log = debug("hardhat:cli:telemetry:analytics:utils");
14+
15+
const ANALYTICS_FILE_NAME = "analytics.json";
16+
17+
export async function getAnalyticsClientId(): Promise<string> {
18+
let clientId = await readAnalyticsClientId();
19+
20+
if (clientId === undefined) {
21+
log("Client Id not found, generating a new one");
22+
23+
clientId = crypto.randomUUID();
24+
await writeAnalyticsClientId(clientId);
25+
}
26+
27+
return clientId;
28+
}
29+
30+
async function readAnalyticsClientId(): Promise<string | undefined> {
31+
const globalTelemetryDir = await getTelemetryDir();
32+
const filePath = path.join(globalTelemetryDir, ANALYTICS_FILE_NAME);
33+
34+
log(`Looking up Client Id at '${filePath}'`);
35+
36+
if ((await exists(filePath)) === false) {
37+
return undefined;
38+
}
39+
40+
const data: AnalyticsFile = await readJsonFile(filePath);
41+
const clientId = data.analytics.clientId;
42+
43+
log(`Client Id found: ${clientId}`);
44+
45+
return clientId;
46+
}
47+
48+
async function writeAnalyticsClientId(clientId: string): Promise<void> {
49+
const globalTelemetryDir = await getTelemetryDir();
50+
const filePath = path.join(globalTelemetryDir, ANALYTICS_FILE_NAME);
51+
await writeJsonFile(filePath, {
52+
analytics: {
53+
clientId,
54+
},
55+
});
56+
57+
log(`Stored clientId '${clientId}'`);
58+
}

0 commit comments

Comments
 (0)