Skip to content

Commit 1a3c1f0

Browse files
committed
feat(analytics): move tracking to the GA4 measurement protocol
Universal Analytics stopped processing data in July 2023, so the UA-111455-* properties the CLI reported to have been discarding every hit since. Replace the universal-analytics client with a direct POST to the GA4 measurement protocol over the existing $httpClient, which keeps proxy and User-Agent handling in one place. Commands are no longer modelled as pageviews. A page_view is keyed off a page_location URL that a CLI does not have, so a command is now its own `command` event carrying command_name, and events attribute to the command that is running. The cdN dimension slots become named event parameters inside the provider, so callers keep using GoogleAnalyticsCustomDimensions. GA_TRACKING_ID gives way to GA_MEASUREMENT_ID and GA_API_SECRET. The measurement id is public and lives in scripts/set-ga-id.js; the api secret is read from the environment so that building this public repository does not report into the production property. Missing configuration warns rather than failing - the provider skips every hit, which is the state a release already shipped.
1 parent 754fa94 commit 1a3c1f0

8 files changed

Lines changed: 396 additions & 123 deletions

File tree

.github/workflows/npm_release_cli.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,17 @@ jobs:
104104
echo NPM_TAG=$NPM_TAG >> $GITHUB_OUTPUT
105105
echo IS_RELEASE=$IS_RELEASE >> $GITHUB_OUTPUT
106106
107+
- name: Check analytics is configured
108+
env:
109+
GA_API_SECRET: ${{ secrets.GA_API_SECRET }}
110+
run: |
111+
if [ -z "$GA_API_SECRET" ]; then
112+
echo "::warning::GA_API_SECRET is not set, so this release reports no analytics."
113+
fi
114+
107115
- name: Build nativescript
116+
env:
117+
GA_API_SECRET: ${{ secrets.GA_API_SECRET }}
108118
run: npm run pack.release
109119

110120
- name: Upload npm package artifact

config/config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
"ANDROID_DEBUG_UI_MAC": "Google Chrome",
55
"USE_POD_SANDBOX": false,
66
"DISABLE_HOOKS": false,
7-
"GA_TRACKING_ID": "UA-111455-51"
7+
"GA_MEASUREMENT_ID": "",
8+
"GA_API_SECRET": ""
89
}

lib/config.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ export class Configuration implements IConfiguration {
1616
DEBUG = false;
1717
ANDROID_DEBUG_UI: string = null;
1818
USE_POD_SANDBOX: boolean = false;
19-
GA_TRACKING_ID: string = null;
19+
GA_MEASUREMENT_ID: string = null;
20+
GA_API_SECRET: string = null;
2021
DISABLE_HOOKS: boolean = false;
2122

2223
/*don't require logger and everything that has logger as dependency in config.js due to cyclic dependency*/
@@ -122,7 +123,7 @@ export class StaticConfig implements IStaticConfig {
122123
["version"],
123124
"exit",
124125
undefined,
125-
{ throwError: false }
126+
{ throwError: false },
126127
);
127128

128129
if (proc.stderr) {
@@ -160,7 +161,7 @@ export class StaticConfig implements IStaticConfig {
160161
"resources",
161162
"platform-tools",
162163
"android",
163-
process.platform
164+
process.platform,
164165
);
165166
const pathToPackageJson = path.join(__dirname, "..", "package.json");
166167
const nsCliVersion = require(pathToPackageJson).version;

lib/declarations.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,8 @@ interface IStaticConfig extends Config.IStaticConfig {}
505505
interface IConfiguration extends Config.IConfig {
506506
ANDROID_DEBUG_UI: string;
507507
USE_POD_SANDBOX: boolean;
508-
GA_TRACKING_ID: string;
508+
GA_MEASUREMENT_ID: string;
509+
GA_API_SECRET: string;
509510
}
510511

511512
interface IApplicationPackage {

lib/services/analytics/google-analytics-provider.ts

Lines changed: 131 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { v4 as uuidv4 } from "uuid";
2-
import * as ua from "universal-analytics";
32
import { AnalyticsClients } from "../../common/constants";
4-
import { cache } from "../../common/decorators";
53
import { IStaticConfig, IConfiguration } from "../../declarations";
64
import {
75
IAnalyticsSettingsService,
6+
IDictionary,
87
IProxyService,
98
IStringDictionary,
9+
Server,
1010
} from "../../common/declarations";
1111
import { GoogleAnalyticsDataType } from "../../common/enums";
1212
import { IGoogleAnalyticsProvider } from "./analytics";
@@ -20,8 +20,28 @@ import { injector } from "../../common/yok";
2020
import { FileLogMessageType } from "../../detached-processes/detached-process-enums";
2121
import { GoogleAnalyticsCustomDimensions } from "../../common/services/analytics/google-analytics-custom-dimensions";
2222

23+
const GA4_COLLECT_URL = "https://www.google-analytics.com/mp/collect";
24+
25+
// Event and parameter names accept only letters, digits and underscores, must
26+
// lead with a letter, and are truncated past these lengths server-side.
27+
const MAX_EVENT_NAME_LENGTH = 40;
28+
const MAX_PARAM_VALUE_LENGTH = 100;
29+
30+
// The Measurement Protocol carries named parameters where the classic protocol
31+
// carried numbered cdN slots, so the dimensions are translated on the way out.
32+
// Callers keep setting GoogleAnalyticsCustomDimensions and never see this.
33+
const GA4_PARAM_NAMES: IStringDictionary = {
34+
[GoogleAnalyticsCustomDimensions.cliVersion]: "cli_version",
35+
[GoogleAnalyticsCustomDimensions.projectType]: "project_type",
36+
[GoogleAnalyticsCustomDimensions.clientID]: "client_uuid",
37+
[GoogleAnalyticsCustomDimensions.sessionID]: "session_id",
38+
[GoogleAnalyticsCustomDimensions.client]: "client",
39+
[GoogleAnalyticsCustomDimensions.nodeVersion]: "node_version",
40+
[GoogleAnalyticsCustomDimensions.isShared]: "is_shared",
41+
};
42+
2343
export class GoogleAnalyticsProvider implements IGoogleAnalyticsProvider {
24-
private currentPage: string;
44+
private currentCommand: string;
2545

2646
constructor(
2747
private clientId: string,
@@ -30,14 +50,15 @@ export class GoogleAnalyticsProvider implements IGoogleAnalyticsProvider {
3050
private $logger: ILogger,
3151
private $proxyService: IProxyService,
3252
private $config: IConfiguration,
53+
private $httpClient: Server.IHttpClient,
3354
private analyticsLoggingService: IFileLogService,
3455
) {}
3556

3657
public async trackHit(trackInfo: IGoogleAnalyticsData): Promise<void> {
3758
const sessionId = uuidv4();
3859

3960
try {
40-
await this.track(this.$config.GA_TRACKING_ID, trackInfo, sessionId);
61+
await this.track(trackInfo, sessionId);
4162
} catch (e) {
4263
this.analyticsLoggingService.logData({
4364
type: FileLogMessageType.Error,
@@ -49,65 +70,111 @@ export class GoogleAnalyticsProvider implements IGoogleAnalyticsProvider {
4970
}
5071
}
5172

52-
@cache()
53-
private getVisitor(gaTrackingId: string, proxy: string): ua.Visitor {
73+
private async track(
74+
trackInfo: IGoogleAnalyticsData,
75+
sessionId: string,
76+
): Promise<void> {
77+
const { GA_MEASUREMENT_ID, GA_API_SECRET } = this.$config;
78+
79+
if (!GA_MEASUREMENT_ID || !GA_API_SECRET) {
80+
this.analyticsLoggingService.logData({
81+
message:
82+
"Google Analytics is not configured (missing measurement id or api secret), skipping hit.",
83+
});
84+
return;
85+
}
86+
87+
const event = this.getEvent(trackInfo, sessionId);
88+
89+
if (!event) {
90+
return;
91+
}
92+
93+
const proxySettings = await this.$proxyService.getCache();
94+
const url = `${GA4_COLLECT_URL}?measurement_id=${encodeURIComponent(
95+
GA_MEASUREMENT_ID,
96+
)}&api_secret=${encodeURIComponent(GA_API_SECRET)}`;
97+
5498
this.analyticsLoggingService.logData({
55-
message: `Initializing Google Analytics visitor for id: ${gaTrackingId} with clientId: ${this.clientId}.`,
99+
message: `Sending Google Analytics event '${event.name}' for clientId: ${this.clientId}.`,
56100
});
57-
const visitor = ua({
58-
tid: gaTrackingId,
59-
cid: this.clientId,
60-
headers: {
61-
["User-Agent"]: this.$analyticsSettingsService.getUserAgentString(
62-
`tnsCli/${this.$staticConfig.version}`,
63-
),
64-
},
65-
requestOptions: {
66-
proxy,
101+
102+
await this.$httpClient.httpRequest(
103+
{
104+
url,
105+
method: "POST",
106+
headers: {
107+
"Content-Type": "application/json",
108+
["User-Agent"]: this.$analyticsSettingsService.getUserAgentString(
109+
`tnsCli/${this.$staticConfig.version}`,
110+
),
111+
},
112+
body: JSON.stringify({
113+
client_id: this.clientId,
114+
// the CLI has no advertising context and must not create one
115+
non_personalized_ads: true,
116+
events: [event],
117+
}),
67118
},
68-
https: true,
69-
});
119+
proxySettings,
120+
);
70121

71122
this.analyticsLoggingService.logData({
72-
message: `Successfully initialized Google Analytics visitor for id: ${gaTrackingId} with clientId: ${this.clientId}.`,
123+
message: `Tracked Google Analytics event '${event.name}'.`,
73124
});
74-
return visitor;
75125
}
76126

77-
private async track(
78-
gaTrackingId: string,
127+
private getEvent(
79128
trackInfo: IGoogleAnalyticsData,
80129
sessionId: string,
81-
): Promise<void> {
82-
const proxySettings = await this.$proxyService.getCache();
83-
const proxy = proxySettings && proxySettings.proxy;
84-
85-
const visitor = this.getVisitor(gaTrackingId, proxy);
86-
87-
await this.setCustomDimensions(
88-
visitor,
130+
): { name: string; params: IDictionary<string | number> } {
131+
const params = this.getCustomDimensionParams(
89132
trackInfo.customDimensions,
90133
sessionId,
91134
);
92135

93136
switch (trackInfo.googleAnalyticsDataType) {
94-
case GoogleAnalyticsDataType.Page:
95-
await this.trackPageView(
96-
visitor,
97-
<IGoogleAnalyticsPageviewData>trackInfo,
98-
);
99-
break;
100-
case GoogleAnalyticsDataType.Event:
101-
await this.trackEvent(visitor, <IGoogleAnalyticsEventData>trackInfo);
102-
break;
137+
case GoogleAnalyticsDataType.Page: {
138+
const pageviewData = <IGoogleAnalyticsPageviewData>trackInfo;
139+
this.currentCommand = pageviewData.path;
140+
141+
// a command is not a page: page_view is keyed off a page_location URL
142+
// this has none, so commands are their own event instead. `title` is
143+
// dropped because callers set it to the same beautified command name.
144+
return {
145+
name: "command",
146+
params: _.assign(params, {
147+
command_name: this.truncate(pageviewData.path),
148+
}),
149+
};
150+
}
151+
case GoogleAnalyticsDataType.Event: {
152+
const eventData = <IGoogleAnalyticsEventData>trackInfo;
153+
154+
return {
155+
name: this.toEventName(eventData.action),
156+
params: _.omitBy(
157+
_.assign(params, {
158+
event_category: this.truncate(eventData.category),
159+
event_label: this.truncate(eventData.label),
160+
value: eventData.value,
161+
// events carry no context of their own, so attribute them to
162+
// the command that is running
163+
command_name: this.truncate(this.currentCommand),
164+
}),
165+
_.isNil,
166+
) as IDictionary<string | number>,
167+
};
168+
}
103169
}
170+
171+
return null;
104172
}
105173

106-
private async setCustomDimensions(
107-
visitor: ua.Visitor,
174+
private getCustomDimensionParams(
108175
customDimensions: IStringDictionary,
109176
sessionId: string,
110-
): Promise<void> {
177+
): IDictionary<string | number> {
111178
const defaultValues: IStringDictionary = {
112179
[GoogleAnalyticsCustomDimensions.cliVersion]: this.$staticConfig.version,
113180
[GoogleAnalyticsCustomDimensions.nodeVersion]: process.version,
@@ -118,78 +185,33 @@ export class GoogleAnalyticsProvider implements IGoogleAnalyticsProvider {
118185
[GoogleAnalyticsCustomDimensions.client]: AnalyticsClients.Unknown,
119186
};
120187

121-
customDimensions = _.merge(defaultValues, customDimensions);
188+
const params: IDictionary<string | number> = {
189+
// realtime reports drop events that report no engagement at all
190+
engagement_time_msec: 1,
191+
};
122192

123-
_.each(customDimensions, (value, key) => {
124-
this.analyticsLoggingService.logData({
125-
message: `Setting custom dimension ${key} to value ${value}`,
126-
});
127-
visitor.set(key, value);
193+
_.each(_.merge(defaultValues, customDimensions), (value, key) => {
194+
if (_.isNil(value)) {
195+
return;
196+
}
197+
198+
params[GA4_PARAM_NAMES[key] || key] = this.truncate(value);
128199
});
200+
201+
return params;
129202
}
130203

131-
private trackEvent(
132-
visitor: ua.Visitor,
133-
trackInfo: IGoogleAnalyticsEventData,
134-
): Promise<void> {
135-
return new Promise<void>((resolve, reject) => {
136-
visitor.event(
137-
trackInfo.category,
138-
trackInfo.action,
139-
trackInfo.label,
140-
trackInfo.value,
141-
{ p: this.currentPage },
142-
(err: Error) => {
143-
if (err) {
144-
this.analyticsLoggingService.logData({
145-
message:
146-
`Unable to track event with category: '${trackInfo.category}', action: '${trackInfo.action}', label: '${trackInfo.label}', ` +
147-
`value: '${trackInfo.value}' attached page: ${this.currentPage}. Error is: ${err}.`,
148-
type: FileLogMessageType.Error,
149-
});
150-
151-
reject(err);
152-
return;
153-
}
154-
155-
this.analyticsLoggingService.logData({
156-
message: `Tracked event with category: '${trackInfo.category}', action: '${trackInfo.action}', label: '${trackInfo.label}', value: '${trackInfo.value}' attached page: ${this.currentPage}.`,
157-
});
158-
resolve();
159-
},
160-
);
161-
});
204+
private toEventName(action: string): string {
205+
const name = (action || "")
206+
.replace(/[^A-Za-z0-9_]/g, "_")
207+
.replace(/^[^A-Za-z]+/, "")
208+
.slice(0, MAX_EVENT_NAME_LENGTH);
209+
210+
return name || "cli_event";
162211
}
163212

164-
private trackPageView(
165-
visitor: ua.Visitor,
166-
trackInfo: IGoogleAnalyticsPageviewData,
167-
): Promise<void> {
168-
return new Promise<void>((resolve, reject) => {
169-
this.currentPage = trackInfo.path;
170-
171-
const pageViewData: ua.PageviewParams = {
172-
dp: trackInfo.path,
173-
dt: trackInfo.title,
174-
};
175-
176-
visitor.pageview(pageViewData, (err) => {
177-
if (err) {
178-
this.analyticsLoggingService.logData({
179-
message: `Unable to track pageview with path '${trackInfo.path}' and title: '${trackInfo.title}' Error is: ${err}.`,
180-
type: FileLogMessageType.Error,
181-
});
182-
183-
reject(err);
184-
return;
185-
}
186-
187-
this.analyticsLoggingService.logData({
188-
message: `Tracked pageview with path '${trackInfo.path}' and title: '${trackInfo.title}'.`,
189-
});
190-
resolve();
191-
});
192-
});
213+
private truncate(value: string): string {
214+
return _.isNil(value) ? value : `${value}`.slice(0, MAX_PARAM_VALUE_LENGTH);
193215
}
194216
}
195217

0 commit comments

Comments
 (0)