Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add logic to send analytics data #5545

Merged
merged 32 commits into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
34a361d
Modify telemetry functions
ChristopherDedominici Jul 22, 2024
24b35bb
add uuid npm package
ChristopherDedominici Jul 23, 2024
4407167
add a method in hh-core to get the telemetry directory
ChristopherDedominici Jul 23, 2024
40b00f5
add main logic (still a few minors TODOs)
ChristopherDedominici Jul 23, 2024
a664c3b
add tests
ChristopherDedominici Jul 23, 2024
648592c
small code reorganization
ChristopherDedominici Jul 24, 2024
e5d5d4a
Merge branch 'v-next' of github.com:NomicFoundation/hardhat into feat…
ChristopherDedominici Jul 24, 2024
94a3abe
Small PR comments' fixes
ChristopherDedominici Jul 24, 2024
c01024a
remove userType. CI is no longer sent
ChristopherDedominici Jul 29, 2024
170f773
remove old analytics client ids
ChristopherDedominici Jul 29, 2024
12a5cd6
Improve naming for functions
ChristopherDedominici Jul 29, 2024
89bbc8a
use builtin method to generate uuid
ChristopherDedominici Jul 29, 2024
f1ab5a4
remove unnecessary lambda functions from subprocess'file
ChristopherDedominici Jul 29, 2024
87f6ab4
use taskId and subtaskId instead of taskName and scopeName
ChristopherDedominici Jul 29, 2024
b4b96ed
use test keys fro google analytics
ChristopherDedominici Jul 29, 2024
6c7dcf9
remove subtaskId, task is an array of string
ChristopherDedominici Jul 29, 2024
52a7c1a
Merge branch 'v-next' of github.com:NomicFoundation/hardhat into feat…
ChristopherDedominici Jul 29, 2024
e3b2c98
Merge branch 'v-next' of github.com:NomicFoundation/hardhat into feat…
ChristopherDedominici Jul 29, 2024
2cb67d4
mark getTelemetryConsent as private and add send user telemetry consent
ChristopherDedominici Jul 30, 2024
12c36c4
small improve in logic and added more tests
ChristopherDedominici Jul 30, 2024
a3202f1
add a helper file for telemetry tests
ChristopherDedominici Aug 2, 2024
0fd6555
add ENV variables to simplify testing (e.g.: force to run in non inte…
ChristopherDedominici Aug 2, 2024
141174d
Add test explanation
ChristopherDedominici Aug 2, 2024
f1c7c7d
move the check on the ENV variable to force telemetry consent in tests
ChristopherDedominici Aug 5, 2024
3e59fa5
modify helper: wait for subprocess file to be readable
ChristopherDedominici Aug 5, 2024
39a1511
add logic to wait for the file to be readable
ChristopherDedominici Aug 5, 2024
b9df707
Smaller fixes based on the PR's comments
ChristopherDedominici Aug 6, 2024
093ae33
Merge branch 'v-next' of github.com:NomicFoundation/hardhat into feat…
ChristopherDedominici Aug 9, 2024
76c4422
add debug module
ChristopherDedominici Aug 9, 2024
ab5014b
add logs in telemetry-permission file
ChristopherDedominici Aug 9, 2024
e9ecacc
additional debug message
ChristopherDedominici Aug 9, 2024
082b407
Merge branch 'v-next' of github.com:NomicFoundation/hardhat into feat…
ChristopherDedominici Aug 15, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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 @@ -11,6 +11,21 @@ export async function getConfigDir(): Promise<string> {
return config;
}

/**
* 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> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make sure that this dir is not deleted by the clean --global task? /cc @schaable

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so, as the global flag deletes the cache path and telemetry uses the data path.

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
2 changes: 2 additions & 0 deletions v-next/hardhat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@
"@ignored/hardhat-vnext-errors": "workspace:^3.0.0-next.2",
"@ignored/hardhat-vnext-utils": "workspace:^3.0.0-next.2",
"@ignored/hardhat-vnext-zod-utils": "workspace:^3.0.0-next.2",
"@types/uuid": "^8.3.1",
"chalk": "^5.3.0",
"enquirer": "^2.3.0",
"tsx": "^4.11.0",
"uuid": "^8.3.2",
"zod": "^3.23.8"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { postJsonRequest } from "@ignored/hardhat-vnext-utils/request";

// These keys are expected to be public
const ANALYTICS_URL = "https://www.google-analytics.com/mp/collect";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these the test or the production values?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Production values

Copy link
Contributor Author

@ChristopherDedominici ChristopherDedominici Jul 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll updated the code with test variables. Before merging the code, I'll replace them with production values.

DO NOT REMOVE THIS COMMENT, it works as a reminder

const API_SECRET = "fQ5joCsDRTOp55wX8a2cVw";
const MEASUREMENT_ID = "G-8LQ007N2QJ";

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

await postJsonRequest(ANALYTICS_URL, payload, {
queryParams: {
api_secret: API_SECRET,
measurement_id: MEASUREMENT_ID,
},
});
})().catch((_err: unknown) => {});
110 changes: 110 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,110 @@
import type {
EventNames,
Payload,
TaskParams,
TelemetryConsentPayload,
} from "./types.js";

import os from "node:os";

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

import { getHardhatVersion } from "../../../utils/package.js";
import { getTelemetryConsent } from "../telemetry-consent.js";

import { getClientId, getUserType } from "./utils.js";

// TODO:log const log = debug("hardhat:core:global-dir");

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

export async function sendTelemetryConsentAnalytics(
userConsent: boolean,
): Promise<void> {
// This is a special scenario where only the consent is sent, all the other analytics info
// (like node version, hardhat version, etc.) are stripped.
const payload: TelemetryConsentPayload = {
client_id: "hardhat_telemetry_consent",
user_id: "hardhat_telemetry_consent",
user_properties: {},
events: [
{
name: "TelemetryConsentResponse",
params: {
userConsent: userConsent ? "yes" : "no",
},
},
],
};

await createSubprocessToSendAnalytics(payload);
}

export async function sendTaskAnalytics(
taskName: string,
scopeName: string | undefined,
): Promise<boolean> {
const eventParams: TaskParams = {
task: taskName,
scope: scopeName,
};

return sendAnalytics("task", eventParams);
}

// Return a boolean for test purposes, so we can check if the analytics was sent based on the consent value
async function sendAnalytics(
eventName: EventNames,
eventParams: TaskParams,
): Promise<boolean> {
if (!(await getTelemetryConsent())) {
return false;
}

const payload = await buildPayload(eventName, eventParams);

await createSubprocessToSendAnalytics(payload);

return true;
}

async function createSubprocessToSendAnalytics(
payload: TelemetryConsentPayload | Payload,
): Promise<void> {
// The file extension is 'js' because the 'ts' file will be compiled
const analyticsSubprocessFilePath = `${import.meta.dirname}/analytics-subprocess.js`;

await spawnDetachedSubProcess(analyticsSubprocessFilePath, [
JSON.stringify(payload),
]);
}

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

return {
client_id: clientId,
user_id: clientId,
user_properties: {
projectId: { value: "hardhat-project" },
userType: { value: getUserType() },
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,
},
},
],
};
}
67 changes: 67 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,67 @@
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;
scope?: string;
}

export interface Payload extends BasePayload {
user_properties: {
projectId: {
value: string;
};
userType: {
value: string;
};
hardhatVersion: {
value: string;
};
operatingSystem: {
value: string;
};
nodeVersion: {
value: string;
};
};
events: Array<{
name: EventNames;
params: {
engagement_time_msec: string;
session_id: string;
} & TaskParams;
}>;
}
90 changes: 90 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,90 @@
import type { AnalyticsFile } from "./types.js";

import os from "node:os";
import path from "node:path";

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

const ANALYTICS_FILE_NAME = "analytics.json";

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

if (clientId === undefined) {
// If the clientId is not undefined and it is of type "firstLegacy" or "secondLegacy," store it in the new file format
clientId =
(await readSecondLegacyAnalyticsId()) ??
(await readFirstLegacyAnalyticsId());

if (clientId === undefined) {
const { v4: uuid } = await import("uuid");
// TODO:log log("Client Id not found, generating a new one");
clientId = uuid();
}

await writeAnalyticsId(clientId);
}

return clientId;
}

export function getUserType(): string {
return isCi() ? "CI" : "Developer";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't send analytics hits from CIs anymore, so we should remove the userType

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

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

/**
* This is the first way that the analytics id was saved.
*/
function readFirstLegacyAnalyticsId(): Promise<string | undefined> {
const oldIdFile = path.join(os.homedir(), ".buidler", "config.json");
return readId(oldIdFile);
}

/**
* This is the same way the analytics id is saved now, but using buidler as the
* name of the project for env-paths
*/
async function readSecondLegacyAnalyticsId(): Promise<string | undefined> {
const globalTelemetryDir = await getTelemetryDir("buidler");
const filePath = path.join(globalTelemetryDir, ANALYTICS_FILE_NAME);
return readId(filePath);
}

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

// TODO:log log(`Stored clientId ${clientId}`);
}

async function readId(filePath: string): Promise<string | undefined> {
// TODO:log 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;

// TODO:log log(`Client Id found: ${clientId}`);
return clientId;
}
28 changes: 23 additions & 5 deletions v-next/hardhat/src/internal/cli/telemetry/telemetry-consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,32 @@ interface TelemetryConsent {
}

/**
* Get the user's telemetry consent. If already provided, returns the answer.
* If not, prompts the user.
* Ensure that the user's telemetry consent is set. If the consent is already provided, returns the answer.
* If not, prompts the user to provide it.
* Consent is only asked in interactive environments.
* @returns True if the user consents to telemetry, false otherwise.
*/
export async function ensureTelemetryConsent(): Promise<boolean> {
const consent = await getTelemetryConsentIfAlreadySet();
if (consent !== undefined) {
return consent;
}

// Telemetry consent not provided yet, ask for it
return requestTelemetryConsent();
}

/**
* Retrieves the user's telemetry consent status.
* @returns True if the user consents to telemetry, false otherwise.
*/
export async function getTelemetryConsent(): Promise<boolean> {
if (canTelemetryBeEnabled() === false) {
const consent = await getTelemetryConsentIfAlreadySet();
return consent !== undefined ? consent : false;
}

async function getTelemetryConsentIfAlreadySet(): Promise<boolean | undefined> {
if (!canTelemetryBeEnabled()) {
return false;
}

Expand All @@ -33,8 +52,7 @@ export async function getTelemetryConsent(): Promise<boolean> {
.consent;
}

// Telemetry consent not provided yet, ask for it
return requestTelemetryConsent();
return undefined;
}

function canTelemetryBeEnabled(): boolean {
Expand Down
Loading