-
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 8 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
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
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"; | ||
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"; | ||
|
||
(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) => {}); | ||
kanej marked this conversation as resolved.
Show resolved
Hide resolved
|
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, | ||
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 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, | ||
}, | ||
}, | ||
], | ||
}; | ||
} |
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; | ||
}>; | ||
} |
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 = | ||
schaable marked this conversation as resolved.
Show resolved
Hide resolved
|
||
(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"; | ||
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. We don't send analytics hits from CIs anymore, so we should remove the userType 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. |
||
} | ||
|
||
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> { | ||
schaable marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// 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; | ||
} |
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.