11import { v4 as uuidv4 } from "uuid" ;
2- import * as ua from "universal-analytics" ;
32import { AnalyticsClients } from "../../common/constants" ;
4- import { cache } from "../../common/decorators" ;
53import { IStaticConfig , IConfiguration } from "../../declarations" ;
64import {
75 IAnalyticsSettingsService ,
6+ IDictionary ,
87 IProxyService ,
98 IStringDictionary ,
9+ Server ,
1010} from "../../common/declarations" ;
1111import { GoogleAnalyticsDataType } from "../../common/enums" ;
1212import { IGoogleAnalyticsProvider } from "./analytics" ;
@@ -20,8 +20,28 @@ import { injector } from "../../common/yok";
2020import { FileLogMessageType } from "../../detached-processes/detached-process-enums" ;
2121import { 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+
2343export 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 - Z a - z 0 - 9 _ ] / g, "_" )
207+ . replace ( / ^ [ ^ A - Z a - 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