-
Notifications
You must be signed in to change notification settings - Fork 1.5k
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
Changes from 13 commits
34a361d
24b35bb
4407167
40b00f5
a664c3b
648592c
e5d5d4a
94a3abe
c01024a
170f773
12a5cd6
89bbc8a
f1ab5a4
87f6ab4
b4b96ed
6c7dcf9
52a7c1a
e3b2c98
2cb67d4
12c36c4
a3202f1
0fd6555
141174d
f1c7c7d
3e59fa5
39a1511
b9df707
093ae33
76c4422
ab5014b
e9ecacc
082b407
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
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"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are these the test or the production values? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Production values There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"; | ||
|
||
const payload = JSON.parse(process.argv[2]); | ||
|
||
await postJsonRequest(ANALYTICS_URL, payload, { | ||
queryParams: { | ||
api_secret: API_SECRET, | ||
measurement_id: MEASUREMENT_ID, | ||
}, | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
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 { getAnalyticsClientId } 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, | ||
kanej marked this conversation as resolved.
Show resolved
Hide resolved
|
||
): 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 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, | ||
}, | ||
}, | ||
], | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
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; | ||
}; | ||
hardhatVersion: { | ||
value: string; | ||
}; | ||
operatingSystem: { | ||
value: string; | ||
}; | ||
nodeVersion: { | ||
value: string; | ||
}; | ||
}; | ||
events: Array<{ | ||
name: EventNames; | ||
params: { | ||
engagement_time_msec: string; | ||
session_id: string; | ||
} & TaskParams; | ||
}>; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
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"; | ||
|
||
const ANALYTICS_FILE_NAME = "analytics.json"; | ||
|
||
export async function getAnalyticsClientId(): Promise<string> { | ||
let clientId = await readAnalyticsClientId(); | ||
|
||
if (clientId === undefined) { | ||
// TODO:log 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); | ||
|
||
// 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; | ||
} | ||
|
||
async function writeAnalyticsClientId(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}`); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
// This file will be copied to the "analytics" folder so it will be executed as a subprocess. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not used anymore There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done here, with other smaller fixes |
||
// This will allow checking that the subprocess received the correct payload. | ||
|
||
// We need to directly use the node method "fs" instead of "writeJsonFile" because a TypeScript ESM file cannot be imported without compiling it first | ||
import { writeFileSync } from "node:fs"; | ||
import path from "node:path"; | ||
|
||
const PATH_TO_RESULT_FILE = path.join( | ||
process.cwd(), | ||
"test", | ||
"fixture-projects", | ||
"cli", | ||
"telemetry", | ||
"analytics", | ||
"result.json", | ||
); | ||
|
||
const stringifiedPayload = process.argv[2]; | ||
|
||
writeFileSync(PATH_TO_RESULT_FILE, stringifiedPayload); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
{ | ||
"client_id": "hardhat_telemetry_consent", | ||
"user_id": "hardhat_telemetry_consent", | ||
"user_properties": {}, | ||
"events": [ | ||
{ | ||
"name": "TelemetryConsentResponse", | ||
"params": { | ||
"userConsent": "yes" | ||
} | ||
} | ||
] | ||
} |
There was a problem hiding this comment.
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 @schaableThere was a problem hiding this comment.
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 thecache
path and telemetry uses thedata
path.