Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 19 additions & 5 deletions packages/core/types/GoogleAnalyticsParameters.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,44 @@
export interface GoogleAnalyticsParameters {
/**
* @deprecated No longer used in GA4. Kept for backwards compatibility.
* The tracking ID / web property ID. The format is UA-XXXX-Y.
* All collected data is associated by this ID.
* All collected data is associated by this ID.
*/
tid?: string;

/** Application name. Should be always 'igniteui-cli'. */
/**
* @deprecated No longer used in GA4. Kept for backwards compatibility.
* Application name. Should be always 'igniteui-cli'.
*/
an?: string;

/** igniteui-cli application version. */
av?: string;

/** User Agent. We will provide here node version as browser version and the user OS. */
/**
* @deprecated No longer used in GA4. Kept for backwards compatibility.
* User Agent. We will provide here node version as browser version and the user OS.
*/
ua?: string;

/** User unique ID. */
/**
* @deprecated No longer used in GA4. Kept for backwards compatibility.
* User unique ID.
*/
uid?: string;

/** The Protocol version. The current value is '1'. */
/**
* @deprecated No longer used in GA4. Kept for backwards compatibility.
* The Protocol version. The current value is '1'.
*/
v?: number;

/**
* The type of hit. Must be 'screenview', 'event' or 'exception'.
* Use 'screenview' for each command.
* Use 'event' for each user input.
* Use 'exception' for exceptions.
* This is mapped to GA4 event names.
*/
t?: string;

Expand Down
135 changes: 105 additions & 30 deletions packages/core/util/GoogleAnalytics.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createHash} from "crypto";
import * as fs from "fs";
import * as path from "path";
import * as qs from "querystring";
import { GoogleAnalyticsParameters } from "../types";
import { App } from "./App";
import { ProjectConfig } from "./ProjectConfig";
Expand All @@ -14,10 +13,28 @@ class GoogleAnalytics {
protected static userSettings: string = "user-settings.json";
protected static appVersion: string;
protected static npmVersion: string;
protected static trackingID = "UA-392932-23";
// GA4 Measurement ID and API Secret
// To obtain these credentials:
// 1. Go to Google Analytics Admin panel (https://analytics.google.com/analytics/web/)
// 2. Navigate to Admin > Data Streams > Select your stream
// 3. Measurement ID is shown at the top (format: G-XXXXXXXXXX)
// 4. For API Secret: Scroll down to "Measurement Protocol API secrets" > "Create" to generate a new secret
//
// These can be overridden via environment variables:
// - IGNITEUI_CLI_GA4_MEASUREMENT_ID
// - IGNITEUI_CLI_GA4_API_SECRET
//
// NOTE: If environment variables are not set and placeholder values remain, analytics will silently fail.
// This matches the current behavior where invalid credentials result in silent failures.
protected static measurementID = process.env.IGNITEUI_CLI_GA4_MEASUREMENT_ID || "G-XXXXXXXXXX"; // TODO: Replace default with actual GA4 Measurement ID
protected static apiSecret = process.env.IGNITEUI_CLI_GA4_API_SECRET || "XXXXXXXXXXXXXXXXXXXXXXXX"; // TODO: Replace default with actual GA4 API Secret

// GA4 requires engagement_time_msec to be set for events to be processed
// Using a minimal value to satisfy the requirement
protected static readonly GA4_MIN_ENGAGEMENT_TIME = "100";

/**
* Generates http post request with provided parameters and sends it to GA
* Generates http post request with provided parameters and sends it to GA4
* @param parameters Object containing all the parameters to send
*/
public static post(parameters: GoogleAnalyticsParameters) {
Expand All @@ -26,46 +43,104 @@ class GoogleAnalytics {
return;
}

// set GA protocol version. This should be 1
parameters.v = 1;

// set the Tracking ID
parameters.tid = this.trackingID;

// set application version if not set beforehand
if (!parameters.av) {
if (!this.appVersion) {
this.appVersion = Util.version();
}

parameters.av = this.appVersion;
// Get application version if not set
if (!this.appVersion) {
this.appVersion = Util.version();
}

// set application name
parameters.an = App.appName;

// set user agent string. We are using this for detecting the user's OS.
// as well as node version. The latest is set as browser version.
// Get user agent info
const nodeVersion = process.version;
const os = this.getOsForUserAgent();
const npmVersion = this.getNpmVersion();
parameters.ua = `node/${nodeVersion} (${os}) npm/${npmVersion}`;
const userAgent = `node/${nodeVersion} (${os}) npm/${npmVersion}`;

// Get client ID (user ID)
const clientId = this.getUUID();

// Build GA4 event payload
const eventName = this.mapHitTypeToEventName(parameters.t || "event");
const eventParams: any = {
app_name: App.appName,
app_version: parameters.av || this.appVersion,
engagement_time_msec: this.GA4_MIN_ENGAGEMENT_TIME
};

// Map custom dimensions to event parameters
if (parameters.cd) eventParams.screen_name = parameters.cd;
if (parameters.ec) eventParams.event_category = parameters.ec;
if (parameters.ea) eventParams.event_action = parameters.ea;
if (parameters.el) eventParams.event_label = parameters.el;
if (parameters.exd) eventParams.exception_description = parameters.exd;

// Map custom dimensions
if (parameters.cd1) eventParams.framework = parameters.cd1;
if (parameters.cd2) eventParams.project_type = parameters.cd2;
if (parameters.cd3) eventParams.project_name = parameters.cd3;
if (parameters.cd4) eventParams.action = parameters.cd4;
if (parameters.cd5) eventParams.component_group = parameters.cd5;
if (parameters.cd6) eventParams.component_name = parameters.cd6;
if (parameters.cd7) eventParams.template_name = parameters.cd7;
if (parameters.cd8) eventParams.custom_view_name = parameters.cd8;
if (parameters.cd9) eventParams.extra_config = parameters.cd9;
if (parameters.cd10 !== undefined) eventParams.skip_config = parameters.cd10;
if (parameters.cd11 !== undefined) eventParams.skip_git = parameters.cd11;
if (parameters.cd12 !== undefined) eventParams.global = parameters.cd12;
if (parameters.cd13) eventParams.search_term = parameters.cd13;
if (parameters.cd14) eventParams.theme = parameters.cd14;

// set user ID
parameters.uid = this.getUUID();
// Build GA4 request payload
const payload = {
client_id: clientId,
user_properties: {
user_agent: {
value: userAgent
}
},
events: [{
name: eventName,
params: eventParams
}]
};

const payloadString = JSON.stringify(payload);
const fullPath = `/mp/collect?measurement_id=${this.measurementID}&api_secret=${this.apiSecret}`;
const options = {
host: "www.google-analytics.com",
path: fullPath,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payloadString)
}
};

// generate http request and sent it to GA
const queryString = qs.stringify(parameters as {});
const fullPath = "/collect?" + queryString;
const options = { host: "www.google-analytics.com", path: fullPath, method: "POST" };
const https = require("https");
const req = https.request(options);
req.on("error", e => {
// TODO: save all the logs and send them later
// Silently fail analytics errors to avoid disrupting CLI functionality
// In the future, this could log to a file or queue for retry
// Error details: e.message, e.code
});
req.write(payloadString);
req.end();
}

/**
* Maps Universal Analytics hit types to GA4 event names
*/
protected static mapHitTypeToEventName(hitType: string): string {
switch (hitType) {
case "screenview":
return "screen_view";
case "event":
return "cli_event";
case "exception":
return "exception";
default:
return "cli_event";
}
}

protected static getUUID(): string {
const absolutePath = path.join(this.userDataFolder, this.appFolder, this.userSettings);
let UUID = "";
Expand All @@ -74,7 +149,7 @@ class GoogleAnalytics {
} else {
const dirName = path.dirname(absolutePath);
if (!fs.existsSync(dirName)) {
fs.mkdirSync(dirName);
fs.mkdirSync(dirName, { recursive: true });
}

UUID = this.getUserID();
Expand Down
10 changes: 8 additions & 2 deletions spec/unit/GoogleAnalytic-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ describe("Unit - Google Analytic", () => {
}

beforeEach(() => {
request = jasmine.createSpyObj("request", ["on", "end"]);
request = jasmine.createSpyObj("request", ["on", "end", "write"]);
spyOn(https, "request").and.returnValue(request);
serviceSpy =
spyOn(child_process, "execSync").and.returnValue("some string which contains REG_SZ so we can get Machine Key");
while (fs.existsSync(`./output/${testFolder}`)) {
testFolder += 1;
}
fs.mkdirSync(`./output/${testFolder}`);
fs.mkdirSync(`./output/${testFolder}`, { recursive: true });
});

afterEach(() => {
Expand All @@ -41,6 +41,10 @@ describe("Unit - Google Analytic", () => {
const requestOptions = (https.request as jasmine.Spy).calls.mostRecent().args[0];
expect(requestOptions.host).toBe('www.google-analytics.com');
expect(requestOptions.method).toBe('POST');
expect(requestOptions.path).toContain('/mp/collect'); // GA4 endpoint
expect(requestOptions.path).toContain('measurement_id=');
expect(requestOptions.path).toContain('api_secret=');
expect(requestOptions.headers['Content-Type']).toBe('application/json');
expect(request.on).toHaveBeenCalledTimes(1);
expect(request.on).toHaveBeenCalledWith("error", jasmine.any(Function));

Expand All @@ -56,6 +60,8 @@ describe("Unit - Google Analytic", () => {
const requestOptions = (https.request as jasmine.Spy).calls.mostRecent().args[0];
expect(requestOptions.host).toBe('www.google-analytics.com');
expect(requestOptions.method).toBe('POST');
expect(requestOptions.path).toContain('/mp/collect'); // GA4 endpoint
expect(requestOptions.headers['Content-Type']).toBe('application/json');
expect(request.on).toHaveBeenCalledTimes(1);
expect(request.on).toHaveBeenCalledWith("error", jasmine.any(Function));

Expand Down
Loading
Loading