|
| 1 | +/** |
| 2 | + * @license |
| 3 | + * Copyright Google Inc. All Rights Reserved. |
| 4 | + * |
| 5 | + * Use of this source code is governed by an MIT-style license that can be |
| 6 | + * found in the LICENSE file at https://angular.io/license |
| 7 | + * |
| 8 | + */ |
| 9 | +import { analytics } from '@angular-devkit/core'; |
| 10 | +import { execSync } from 'child_process'; |
| 11 | +import * as debug from 'debug'; |
| 12 | +import * as https from 'https'; |
| 13 | +import * as os from 'os'; |
| 14 | +import * as querystring from 'querystring'; |
| 15 | +import { VERSION } from './version'; |
| 16 | + |
| 17 | +interface BaseParameters extends analytics.CustomDimensionsAndMetricsOptions { |
| 18 | + [key: string]: string | number | boolean | undefined | (string | number | boolean | undefined)[]; |
| 19 | +} |
| 20 | + |
| 21 | +interface ScreenviewParameters extends BaseParameters { |
| 22 | + /** Screen Name */ |
| 23 | + cd?: string; |
| 24 | + /** Application Name */ |
| 25 | + an?: string; |
| 26 | + /** Application Version */ |
| 27 | + av?: string; |
| 28 | + /** Application ID */ |
| 29 | + aid?: string; |
| 30 | + /** Application Installer ID */ |
| 31 | + aiid?: string; |
| 32 | +} |
| 33 | + |
| 34 | +interface TimingParameters extends BaseParameters { |
| 35 | + /** User timing category */ |
| 36 | + utc?: string; |
| 37 | + /** User timing variable name */ |
| 38 | + utv?: string; |
| 39 | + /** User timing time */ |
| 40 | + utt?: string | number; |
| 41 | + /** User timing label */ |
| 42 | + utl?: string; |
| 43 | +} |
| 44 | + |
| 45 | +interface PageviewParameters extends BaseParameters { |
| 46 | + /** |
| 47 | + * Document Path |
| 48 | + * The path portion of the page URL. Should begin with '/'. |
| 49 | + */ |
| 50 | + dp?: string; |
| 51 | + /** Document Host Name */ |
| 52 | + dh?: string; |
| 53 | + /** Document Title */ |
| 54 | + dt?: string; |
| 55 | + /** |
| 56 | + * Document location URL |
| 57 | + * Use this parameter to send the full URL (document location) of the page on which content resides. |
| 58 | + */ |
| 59 | + dl?: string; |
| 60 | +} |
| 61 | + |
| 62 | +interface EventParameters extends BaseParameters { |
| 63 | + /** Event Category */ |
| 64 | + ec: string; |
| 65 | + /** Event Action */ |
| 66 | + ea: string; |
| 67 | + /** Event Label */ |
| 68 | + el?: string; |
| 69 | + /** |
| 70 | + * Event Value |
| 71 | + * Specifies the event value. Values must be non-negative. |
| 72 | + */ |
| 73 | + ev?: string | number; |
| 74 | + /** Page Path */ |
| 75 | + p?: string; |
| 76 | + /** Page */ |
| 77 | + dp?: string; |
| 78 | +} |
| 79 | + |
| 80 | +/** |
| 81 | + * See: https://developers.google.com/analytics/devguides/collection/protocol/v1/devguide |
| 82 | + */ |
| 83 | +export class AnalyticsCollector implements analytics.Analytics { |
| 84 | + private trackingEventsQueue: Record<string, string | number | boolean>[] = []; |
| 85 | + private readonly parameters: Record<string, string | number | boolean> = {}; |
| 86 | + private readonly analyticsLogDebug = debug('ng:analytics:log'); |
| 87 | + |
| 88 | + constructor( |
| 89 | + trackingId: string, |
| 90 | + userId: string, |
| 91 | + ) { |
| 92 | + // API Version |
| 93 | + this.parameters['v'] = '1'; |
| 94 | + // User ID |
| 95 | + this.parameters['cid'] = userId; |
| 96 | + // Tracking |
| 97 | + this.parameters['tid'] = trackingId; |
| 98 | + |
| 99 | + this.parameters['ds'] = 'cli'; |
| 100 | + this.parameters['ua'] = _buildUserAgentString(); |
| 101 | + this.parameters['ul'] = _getLanguage(); |
| 102 | + |
| 103 | + // @angular/cli with version. |
| 104 | + this.parameters['an'] = '@angular/cli'; |
| 105 | + this.parameters['av'] = VERSION.full; |
| 106 | + |
| 107 | + // We use the application ID for the Node version. This should be "node v12.10.0". |
| 108 | + const nodeVersion = `node ${process.version}`; |
| 109 | + this.parameters['aid'] = nodeVersion; |
| 110 | + |
| 111 | + // Custom dimentions |
| 112 | + // We set custom metrics for values we care about. |
| 113 | + this.parameters['cd' + analytics.NgCliAnalyticsDimensions.CpuCount] = os.cpus().length; |
| 114 | + // Get the first CPU's speed. It's very rare to have multiple CPUs of different speed (in most |
| 115 | + // non-ARM configurations anyway), so that's all we care about. |
| 116 | + this.parameters['cd' + analytics.NgCliAnalyticsDimensions.CpuSpeed] = Math.floor(os.cpus()[0].speed); |
| 117 | + this.parameters['cd' + analytics.NgCliAnalyticsDimensions.RamInGigabytes] = Math.round(os.totalmem() / (1024 * 1024 * 1024)); |
| 118 | + this.parameters['cd' + analytics.NgCliAnalyticsDimensions.NodeVersion] = nodeVersion; |
| 119 | + } |
| 120 | + |
| 121 | + event(ec: string, ea: string, options: analytics.EventOptions = {}): void { |
| 122 | + const { label: el, value: ev, metrics, dimensions } = options; |
| 123 | + this.addToQueue('event', { ec, ea, el, ev, metrics, dimensions }); |
| 124 | + } |
| 125 | + |
| 126 | + pageview(dp: string, options: analytics.PageviewOptions = {}): void { |
| 127 | + const { hostname: dh, title: dt, metrics, dimensions } = options; |
| 128 | + this.addToQueue('pageview', { dp, dh, dt, metrics, dimensions }); |
| 129 | + } |
| 130 | + |
| 131 | + timing(utc: string, utv: string, utt: string | number, options: analytics.TimingOptions = {}): void { |
| 132 | + const { label: utl, metrics, dimensions } = options; |
| 133 | + this.addToQueue('timing', { utc, utv, utt, utl, metrics, dimensions }); |
| 134 | + } |
| 135 | + |
| 136 | + screenview(cd: string, an: string, options: analytics.ScreenviewOptions = {}): void { |
| 137 | + const { appVersion: av, appId: aid, appInstallerId: aiid, metrics, dimensions } = options; |
| 138 | + this.addToQueue('screenview', { cd, an, av, aid, aiid, metrics, dimensions }); |
| 139 | + } |
| 140 | + |
| 141 | + async flush(): Promise<void> { |
| 142 | + const pending = this.trackingEventsQueue.length; |
| 143 | + this.analyticsLogDebug(`flush queue size: ${pending}`); |
| 144 | + |
| 145 | + if (!pending) { |
| 146 | + return; |
| 147 | + } |
| 148 | + |
| 149 | + // The below is needed so that if flush is called multiple times, |
| 150 | + // we don't report the same event multiple times. |
| 151 | + const pendingTrackingEvents = this.trackingEventsQueue; |
| 152 | + this.trackingEventsQueue = []; |
| 153 | + |
| 154 | + try { |
| 155 | + await this.send(pendingTrackingEvents); |
| 156 | + } catch (error) { |
| 157 | + // Failure to report analytics shouldn't crash the CLI. |
| 158 | + this.analyticsLogDebug('send error: %j', error); |
| 159 | + } |
| 160 | + } |
| 161 | + |
| 162 | + private addToQueue(eventType: 'event', parameters: EventParameters): void; |
| 163 | + private addToQueue(eventType: 'pageview', parameters: PageviewParameters): void; |
| 164 | + private addToQueue(eventType: 'timing', parameters: TimingParameters): void; |
| 165 | + private addToQueue(eventType: 'screenview', parameters: ScreenviewParameters): void; |
| 166 | + private addToQueue(eventType: 'event' | 'pageview' | 'timing' | 'screenview', parameters: BaseParameters): void { |
| 167 | + const { metrics, dimensions, ...restParameters } = parameters; |
| 168 | + const data = { |
| 169 | + ...this.parameters, |
| 170 | + ...restParameters, |
| 171 | + ...this.customVariables({ metrics, dimensions }), |
| 172 | + t: eventType, |
| 173 | + }; |
| 174 | + |
| 175 | + this.analyticsLogDebug('add event to queue: %j', data); |
| 176 | + this.trackingEventsQueue.push(data); |
| 177 | + } |
| 178 | + |
| 179 | + private async send(data: Record<string, string | number | boolean>[]): Promise<void> { |
| 180 | + this.analyticsLogDebug('send event: %j', data); |
| 181 | + |
| 182 | + return new Promise<void>((resolve, reject) => { |
| 183 | + const request = https.request({ |
| 184 | + host: 'www.google-analytics.com', |
| 185 | + method: 'POST', |
| 186 | + path: data.length > 1 ? '/batch' : '/collect', |
| 187 | + }, response => { |
| 188 | + if (response.statusCode !== 200) { |
| 189 | + reject(new Error(`Analytics reporting failed with status code: ${response.statusCode}.`)); |
| 190 | + |
| 191 | + return; |
| 192 | + } |
| 193 | + }); |
| 194 | + |
| 195 | + request.on('error', reject); |
| 196 | + |
| 197 | + const queryParameters = data.map(p => querystring.stringify(p)).join('\n'); |
| 198 | + request.write(queryParameters); |
| 199 | + request.end(resolve); |
| 200 | + }); |
| 201 | + } |
| 202 | + |
| 203 | + /** |
| 204 | + * Creates the dimension and metrics variables to add to the queue. |
| 205 | + * @private |
| 206 | + */ |
| 207 | + private customVariables(options: analytics.CustomDimensionsAndMetricsOptions): Record<string, string | number | boolean> { |
| 208 | + const additionals: Record<string, string | number | boolean> = {}; |
| 209 | + |
| 210 | + const { dimensions, metrics } = options; |
| 211 | + dimensions?.forEach((v, i) => additionals[`cd${i}`] = v); |
| 212 | + metrics?.forEach((v, i) => additionals[`cm${i}`] = v); |
| 213 | + |
| 214 | + return additionals; |
| 215 | + } |
| 216 | +} |
| 217 | + |
| 218 | +// These are just approximations of UA strings. We just try to fool Google Analytics to give us the |
| 219 | +// data we want. |
| 220 | +// See https://developers.whatismybrowser.com/useragents/ |
| 221 | +const osVersionMap: Readonly<{ [os: string]: { [release: string]: string } }> = { |
| 222 | + darwin: { |
| 223 | + '1.3.1': '10_0_4', |
| 224 | + '1.4.1': '10_1_0', |
| 225 | + '5.1': '10_1_1', |
| 226 | + '5.2': '10_1_5', |
| 227 | + '6.0.1': '10_2', |
| 228 | + '6.8': '10_2_8', |
| 229 | + '7.0': '10_3_0', |
| 230 | + '7.9': '10_3_9', |
| 231 | + '8.0': '10_4_0', |
| 232 | + '8.11': '10_4_11', |
| 233 | + '9.0': '10_5_0', |
| 234 | + '9.8': '10_5_8', |
| 235 | + '10.0': '10_6_0', |
| 236 | + '10.8': '10_6_8', |
| 237 | + // We stop here because we try to math out the version for anything greater than 10, and it |
| 238 | + // works. Those versions are standardized using a calculation now. |
| 239 | + }, |
| 240 | + win32: { |
| 241 | + '6.3.9600': 'Windows 8.1', |
| 242 | + '6.2.9200': 'Windows 8', |
| 243 | + '6.1.7601': 'Windows 7 SP1', |
| 244 | + '6.1.7600': 'Windows 7', |
| 245 | + '6.0.6002': 'Windows Vista SP2', |
| 246 | + '6.0.6000': 'Windows Vista', |
| 247 | + '5.1.2600': 'Windows XP', |
| 248 | + }, |
| 249 | +}; |
| 250 | + |
| 251 | +/** |
| 252 | + * Build a fake User Agent string. This gets sent to Analytics so it shows the proper OS version. |
| 253 | + * @private |
| 254 | + */ |
| 255 | +function _buildUserAgentString() { |
| 256 | + switch (os.platform()) { |
| 257 | + case 'darwin': { |
| 258 | + let v = osVersionMap.darwin[os.release()]; |
| 259 | + |
| 260 | + if (!v) { |
| 261 | + // Remove 4 to tie Darwin version to OSX version, add other info. |
| 262 | + const x = parseFloat(os.release()); |
| 263 | + if (x > 10) { |
| 264 | + v = `10_` + (x - 4).toString().replace('.', '_'); |
| 265 | + } |
| 266 | + } |
| 267 | + |
| 268 | + const cpuModel = os.cpus()[0].model.match(/^[a-z]+/i); |
| 269 | + const cpu = cpuModel ? cpuModel[0] : os.cpus()[0].model; |
| 270 | + |
| 271 | + return `(Macintosh; ${cpu} Mac OS X ${v || os.release()})`; |
| 272 | + } |
| 273 | + |
| 274 | + case 'win32': |
| 275 | + return `(Windows NT ${os.release()})`; |
| 276 | + |
| 277 | + case 'linux': |
| 278 | + return `(X11; Linux i686; ${os.release()}; ${os.cpus()[0].model})`; |
| 279 | + |
| 280 | + default: |
| 281 | + return os.platform() + ' ' + os.release(); |
| 282 | + } |
| 283 | +} |
| 284 | + |
| 285 | + |
| 286 | +/** |
| 287 | + * Get a language code. |
| 288 | + * @private |
| 289 | + */ |
| 290 | +function _getLanguage() { |
| 291 | + // Note: Windows does not expose the configured language by default. |
| 292 | + return ( |
| 293 | + process.env.LANG || // Default Unix env variable. |
| 294 | + process.env.LC_CTYPE || // For C libraries. Sometimes the above isn't set. |
| 295 | + process.env.LANGSPEC || // For Windows, sometimes this will be set (not always). |
| 296 | + _getWindowsLanguageCode() || |
| 297 | + '??' |
| 298 | + ); // ¯\_(ツ)_/¯ |
| 299 | +} |
| 300 | + |
| 301 | +/** |
| 302 | + * Attempt to get the Windows Language Code string. |
| 303 | + * @private |
| 304 | + */ |
| 305 | +function _getWindowsLanguageCode(): string | undefined { |
| 306 | + if (!os.platform().startsWith('win')) { |
| 307 | + return undefined; |
| 308 | + } |
| 309 | + |
| 310 | + try { |
| 311 | + // This is true on Windows XP, 7, 8 and 10 AFAIK. Would return empty string or fail if it |
| 312 | + // doesn't work. |
| 313 | + return execSync('wmic.exe os get locale').toString().trim(); |
| 314 | + } catch { } |
| 315 | + |
| 316 | + return undefined; |
| 317 | +} |
0 commit comments