-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #5545 from NomicFoundation/feature/add-logic-to-se…
…nd-telemetry-data Add logic to send analytics data
- Loading branch information
Showing
10 changed files
with
610 additions
and
43 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
133 changes: 133 additions & 0 deletions
133
v-next/hardhat/src/internal/cli/telemetry/analytics/analytics.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
22
v-next/hardhat/src/internal/cli/telemetry/analytics/subprocess.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
63
v-next/hardhat/src/internal/cli/telemetry/analytics/types.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
58
v-next/hardhat/src/internal/cli/telemetry/analytics/utils.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}'`); | ||
} |
Oops, something went wrong.