@google-cloud/nodejs-common 0.9.4-beta3 → 0.9.9-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,31 +30,88 @@ const {
30
30
  },
31
31
  services: {
32
32
  UploadClickConversionsRequest,
33
+ UploadClickConversionsResponse,
33
34
  UploadUserDataRequest,
35
+ UploadUserDataResponse,
34
36
  UserDataOperation,
35
37
  SearchGoogleAdsFieldsRequest,
36
38
  },
39
+ errors: {
40
+ GoogleAdsFailure,
41
+ },
37
42
  } = googleAdsLib;
38
43
  const {GoogleAdsApi} = require('google-ads-api');
44
+ const lodash = require('lodash');
45
+
39
46
  const AuthClient = require('./auth_client.js');
40
- const {getLogger} = require('../components/utils.js');
47
+ const {getLogger, BatchResult,} = require('../components/utils.js');
41
48
 
42
49
  /** @type {!ReadonlyArray<string>} */
43
50
  const API_SCOPES = Object.freeze(['https://www.googleapis.com/auth/adwords',]);
44
51
 
52
+ /**
53
+ * List of properties that will be taken from the data file as elements of a
54
+ * conversion.
55
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/ClickConversion
56
+ * @type {Array<string>}
57
+ */
58
+ const PICKED_PROPERTIES = [
59
+ 'external_attribution_data',
60
+ 'cart_data',
61
+ 'user_identifiers',
62
+ 'gclid',
63
+ 'conversion_action',
64
+ 'conversion_date_time',
65
+ 'conversion_value',
66
+ 'currency_code',
67
+ 'order_id',
68
+ ];
69
+
70
+ /**
71
+ * Kinds of UserIdentifier.
72
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserIdentifier
73
+ * @type {Array<string>}
74
+ */
75
+ const IDENTIFIERS = [
76
+ 'hashed_email',
77
+ 'hashed_phone_number',
78
+ 'mobile_id',
79
+ 'third_party_user_id',
80
+ 'address_info',
81
+ ];
82
+
45
83
  /**
46
84
  * Configuration for uploading click conversions for Google Ads, includes:
47
85
  * gclid, conversion_action, conversion_date_time, conversion_value,
48
- * currency_code, order_id, external_attribution_data
49
- * @see https://developers.google.com/google-ads/api/reference/rpc/v7/ClickConversion
86
+ * currency_code, order_id, external_attribution_data, etc.
87
+ * @see PICKED_PROPERTIES
88
+ *
89
+ * Other properties that will be used to build the conversions but not picked by
90
+ * the value directly including:
91
+ * 1. 'user_identifier_source', source of the user identifier. If there is user
92
+ * identifiers information in the conversion, this property should be set as
93
+ * 'FIRST_PARTY'.
94
+ * @see IDENTIFIERS
95
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserIdentifier?hl=en
96
+ * 2. 'custom_variable_tags', the tags of conversion custom variables. To upload
97
+ * custom variables, 'conversion_custom_variable_id' is required rather than the
98
+ * 'tag'. So the invoker is expected to use the function
99
+ * 'getConversionCustomVariableId' to get the ids and pass in as a
100
+ * map(customVariables) of <tag, id> pairs before uploading conversions.
101
+ *
102
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/ClickConversion
50
103
  * @typedef {{
104
+ * external_attribution_data: (GoogleAdsApi.ExternalAttributionData|undefined),
105
+ * cart_data: (object|undefined),
51
106
  * gclid: string,
52
107
  * conversion_action: string,
53
108
  * conversion_date_time: string,
54
109
  * conversion_value: number,
55
110
  * currency_code:(string|undefined),
56
111
  * order_id: (string|undefined),
57
- * external_attribution_data: (GoogleAdsApi.ExternalAttributionData|undefined),
112
+ * user_identifier_source:(UserIdentifierSource|undefined),
113
+ * custom_variable_tags:(!Array<string>|undefined),
114
+ * customVariables:(!object<string,string>|undefined),
58
115
  * }}
59
116
  */
60
117
  let ClickConversionConfig;
@@ -65,7 +122,7 @@ let ClickConversionConfig;
65
122
  * list_type must be one of the following: hashed_email,
66
123
  * hashed_phone_number, mobile_id, third_party_user_id or address_info;
67
124
  * operation must be one of the two: 'create' or 'remove';
68
- * @see https://developers.google.com/google-ads/api/reference/rpc/v7/UserDataOperation
125
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserDataOperation
69
126
  * @typedef {{
70
127
  * customer_id: string,
71
128
  * login_customer_id: string,
@@ -80,7 +137,7 @@ let CustomerMatchConfig;
80
137
  /**
81
138
  * Configuration for uploading customer match data for Google Ads, includes one of:
82
139
  * hashed_email, hashed_phone_number, mobile_id, third_party_user_id or address_info
83
- * @see https://developers.google.com/google-ads/api/reference/rpc/v7/UserIdentifier
140
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserIdentifier
84
141
  * @typedef {{
85
142
  * hashed_email: string,
86
143
  * }|{
@@ -123,17 +180,21 @@ let CustomerMatchRecord;
123
180
  let ReportQueryConfig;
124
181
 
125
182
  /**
126
- * Google Ads API v6.1 stub.
183
+ * Google Ads API class based on Opteo's Nodejs library.
127
184
  * see https://opteo.com/dev/google-ads-api/#features
128
185
  */
129
186
  class GoogleAds {
130
187
  /**
131
188
  * Note: Rate limits is set by the access level of Developer token.
132
189
  * @param {string} developerToken Developer token to access the API.
190
+ * @param {boolean=} debugMode This is used to set ONLY validate conversions
191
+ * but not real uploading.
192
+ * @param {!Object<string,string>=} env The environment object to hold env
193
+ * variables.
133
194
  */
134
- constructor(developerToken) {
135
- this.debug = process.env['DEBUG'] === 'true';
136
- const oauthClient = new AuthClient(API_SCOPES).getOAuth2Token();
195
+ constructor(developerToken, debugMode = false, env = process.env) {
196
+ this.debugMode = debugMode;
197
+ const oauthClient = new AuthClient(API_SCOPES, env).getOAuth2Token();
137
198
  /** @const {GoogleAdsApi} */ this.apiClient = new GoogleAdsApi({
138
199
  client_id: oauthClient.clientId,
139
200
  client_secret: oauthClient.clientSecret,
@@ -206,25 +267,171 @@ class GoogleAds {
206
267
  * @param {!Array<string>} lines Data for single request. It should be
207
268
  * guaranteed that it doesn't exceed quota limitation.
208
269
  * @param {string} batchId The tag for log.
209
- * @return {!Promise<boolean>}
270
+ * @return {!BatchResult}
210
271
  */
211
272
  return async (lines, batchId) => {
212
273
  /** @type {!Array<ClickConversionConfig>} */
213
- const conversions = lines.map((line) => {
214
- const record = JSON.parse(line);
215
- return Object.assign({}, adsConfig, record);
216
- });
274
+ const conversions = lines.map(
275
+ (line) => buildClickConversionFromLine(line, adsConfig, customerId));
276
+ /** @const {BatchResult} */
277
+ const batchResult = {
278
+ result: true,
279
+ numberOfLines: lines.length,
280
+ };
217
281
  try {
218
- return await this.uploadClickConversions(conversions, customerId,
219
- loginCustomerId);
282
+ const response = await this.uploadClickConversions(conversions,
283
+ customerId, loginCustomerId);
284
+ const {results, partial_failure_error: failed} = response;
285
+ if (this.logger.isDebugEnabled()) {
286
+ const gclids = results.map((conversion) => conversion.gclid);
287
+ this.logger.debug('Uploaded gclids:', gclids);
288
+ }
289
+ if (failed) {
290
+ this.logger.info('partial_failure_error:', failed.message);
291
+ const failures = failed.details.map(
292
+ ({value}) => GoogleAdsFailure.decode(value));
293
+ this.extraFailedLines_(batchResult, failures, lines, 0);
294
+ }
295
+ return batchResult;
220
296
  } catch (error) {
221
- this.logger.info(
222
- `Error in getUploadConFn in batchId: ${batchId}`, error);
223
- return false;
297
+ this.logger.error(
298
+ `Error in upload conversions batch: ${batchId}`, error);
299
+ this.updateBatchResultWithError_(batchResult, error, lines, 0);
300
+ return batchResult;
224
301
  }
225
302
  }
226
303
  }
227
304
 
305
+ /**
306
+ * Updates the BatchResult based on errors.
307
+ *
308
+ * There are 2 types of errors here:
309
+ * 1. Normal JavaScript Error object. It happens when the whole process fails
310
+ * (not partial failure), so there is no detailed failed lines.
311
+ * 2. GoogleAdsFailure. It is a Google Ads' own error object which has an
312
+ * array of GoogleAdsError (property name 'errors'). GoogleAdsError contains
313
+ * the detailed failed data if it is a line-error. For example, a wrong
314
+ * encoded user identifier is a line-error, while a wrong user list id is not.
315
+ * GoogleAdsFailure: https://developers.google.com/google-ads/api/reference/rpc/latest/GoogleAdsFailure
316
+ * GoogleAdsError: https://developers.google.com/google-ads/api/reference/rpc/latest/GoogleAdsError
317
+ *
318
+ * For Customer Match data uploading, there is not partial failure, so the
319
+ * result can be either succeeded or a thrown error. The thrown error will be
320
+ * used to build the returned result here.
321
+ * For Conversions uploading (partial failure enabled), if there is an error
322
+ * fails the whole process, the error will also be thrown and handled here.
323
+ * Otherwise, the errors will be wrapped in the response as the property named
324
+ * 'partial_failure_error' which contains an array of GoogleAdsFailure. This
325
+ * kind of failure doesn't fail the process, while line-errors can be
326
+ * extracted from it.
327
+ * For more information, see the function `extraFailedLines_`.
328
+ *
329
+ * An example of 'GoogleAdsFailure' is:
330
+ * GoogleAdsFailure {
331
+ * errors: [
332
+ * GoogleAdsError {
333
+ * error_code: ErrorCode { offline_user_data_job_error: 25 },
334
+ * message: 'The SHA256 encoded value is malformed.',
335
+ * location: ErrorLocation {
336
+ * field_path_elements: [
337
+ * FieldPathElement { field_name: 'operations', index: 0 },
338
+ * FieldPathElement { field_name: 'create' },
339
+ * FieldPathElement { field_name: 'user_identifiers', index: 0 },
340
+ * FieldPathElement { field_name: 'hashed_email' }
341
+ * ]
342
+ * }
343
+ * }
344
+ * ],
345
+ * request_id: 'xxxxxxxxxxxxxxx'
346
+ * }
347
+ *
348
+ * @param {!BatchResult} batchResult
349
+ * @param {(!GoogleAdsFailure|!Error)} error
350
+ * @param {!Array<string>} lines The original input data.
351
+ * @param {number} fieldPathIndex The index of 'FieldPathElement' in the array
352
+ * 'field_path_elements'. This is used to get the original line related to
353
+ * this GoogleAdsError.
354
+ * @private
355
+ */
356
+ updateBatchResultWithError_(batchResult, error, lines, fieldPathIndex) {
357
+ batchResult.result = false;
358
+ if (error.errors) { //GoogleAdsFailure
359
+ this.extraFailedLines_(batchResult, [error], lines, fieldPathIndex);
360
+ } else {
361
+ batchResult.errors = [error.message || error.toString()];
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Extras failed lines based on the GoogleAdsFailures.
367
+ *
368
+ * Different errors have different 'fieldPathIndex' which is the index of
369
+ * failed lines in original input data (an array of a string).
370
+ *
371
+ * For conversions, the ErrorLocation is like:
372
+ * ErrorLocation {
373
+ * field_path_elements: [
374
+ * FieldPathElement { field_name: 'operations', index: 0 },
375
+ * FieldPathElement { field_name: 'create' }
376
+ * ]
377
+ * }
378
+ * So the index is 0, index of 'operations'.
379
+ *
380
+ * For customer match upload, the ErrorLocation is like:
381
+ * ErrorLocation {
382
+ * field_path_elements: [
383
+ * FieldPathElement { field_name: 'operations', index: 0 },
384
+ * FieldPathElement { field_name: 'create' },
385
+ * FieldPathElement { field_name: 'user_identifiers', index: 0 },
386
+ * FieldPathElement { field_name: 'hashed_email' }
387
+ * ]
388
+ * }
389
+ * The index should be 2, index of 'user_identifiers'.
390
+ *
391
+ * With this we can get errors and failed lines. The function will set
392
+ * following for the given BatchResult object:
393
+ * result - false
394
+ * errors - de-duplicated error reasons
395
+ * failedLines - failed lines, an array of string. Without the reason of
396
+ * failure.
397
+ * groupedFailed - a hashmap of failed the lines. The key is the reason, the
398
+ * value is the array of failed lines due to this reason.
399
+ * @param {!BatchResult} batchResult
400
+ * @param {!Array<!GoogleAdsFailure>} failures
401
+ * @param {!Array<string>} lines The original input data.
402
+ * @param {number} fieldPathIndex The index of 'FieldPathElement' in the array
403
+ * 'field_path_elements'. This is used to get the original line related to
404
+ * this GoogleAdsError.
405
+ * @private
406
+ */
407
+ extraFailedLines_(batchResult, failures, lines, fieldPathIndex) {
408
+ batchResult.result = false;
409
+ batchResult.failedLines = [];
410
+ batchResult.groupedFailed = {};
411
+ const errors = new Set();
412
+ failures.forEach((failure) => {
413
+ failure.errors.forEach(({message, location}) => {
414
+ errors.add(message);
415
+ if (location && location.field_path_elements[fieldPathIndex]) {
416
+ const {index} = location.field_path_elements[fieldPathIndex];
417
+ if (typeof index === 'undefined') {
418
+ this.logger.warn(`Unknown field path index: ${fieldPathIndex}`,
419
+ location.field_path_elements);
420
+ } else {
421
+ const groupedFailed = batchResult.groupedFailed[message] || [];
422
+ const failedLine = lines[index];
423
+ batchResult.failedLines.push(failedLine);
424
+ groupedFailed.push(failedLine);
425
+ if (groupedFailed.length === 1) {
426
+ batchResult.groupedFailed[message] = groupedFailed;
427
+ }
428
+ }
429
+ }
430
+ });
431
+ });
432
+ batchResult.errors = Array.from(errors);
433
+ }
434
+
228
435
  /**
229
436
  * Uploads click conversions to google ads account.
230
437
  * It requires an array of click conversions and customer id.
@@ -232,30 +439,38 @@ class GoogleAds {
232
439
  * @param {Array<ClickConversionConfig>} clickConversions ClickConversions
233
440
  * @param {string} customerId
234
441
  * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
235
- * @return {!Promise<boolean>}
442
+ * @return {!Promise<!UploadClickConversionsResponse>}
236
443
  */
237
- async uploadClickConversions(clickConversions, customerId, loginCustomerId) {
444
+ uploadClickConversions(clickConversions, customerId, loginCustomerId) {
238
445
  this.logger.debug('Upload click conversions for customerId:', customerId);
239
446
  const customer = this.getGoogleAdsApiCustomer_(loginCustomerId, customerId);
240
447
  const request = new UploadClickConversionsRequest({
241
448
  conversions: clickConversions,
242
449
  customer_id: customerId,
243
- validate_only: this.debug, // when true makes no changes
450
+ validate_only: this.debugMode, // when true makes no changes
244
451
  partial_failure: true, // Will still create the non-failed entities
245
452
  });
246
- const result = await customer.conversionUploads.uploadClickConversions(
247
- request);
248
- const {results, partial_failure_error: failed} = result;
249
- const response = results.map((conversion) => conversion.gclid);
250
- this.logger.debug('Uploaded gclids:', response);
251
- // const failed = result.partial_failure_error;
252
- // Note: the response is different from previous version. current 'message'
253
- // only contains partial failed conversions. The other field 'details'
254
- // contains more information with the type of Array<Buffer>.
255
- if (failed) {
256
- this.logger.info('Errors:', failed.message);
453
+ return customer.conversionUploads.uploadClickConversions(request);
454
+ }
455
+
456
+ /**
457
+ * Returns the id of Conversion Custom Variable with the given tag.
458
+ * @param {string} tag Custom Variable tag.
459
+ * @param {string} customerId
460
+ * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
461
+ * @return {Promise<number|undefined>} Returns undefined if can't find tag.
462
+ */
463
+ async getConversionCustomVariableId(tag, customerId, loginCustomerId) {
464
+ const customer = this.getGoogleAdsApiCustomer_(loginCustomerId, customerId);
465
+ const customVariables = await customer.query(`
466
+ SELECT conversion_custom_variable.id,
467
+ conversion_custom_variable.tag
468
+ FROM conversion_custom_variable
469
+ WHERE conversion_custom_variable.tag = "${tag}" LIMIT 1
470
+ `);
471
+ if (customVariables.length > 0) {
472
+ return customVariables[0].conversion_custom_variable.id;
257
473
  }
258
- return !failed;
259
474
  }
260
475
 
261
476
  /**
@@ -271,29 +486,36 @@ class GoogleAds {
271
486
  * @param {!Array<string>} lines Data for single request. It should be
272
487
  * guaranteed that it doesn't exceed quota limitation.
273
488
  * @param {string} batchId The tag for log.
274
- * @return {!Promise<boolean>}
489
+ * @return {!Promise<BatchResult>}
275
490
  */
276
491
  return async (lines, batchId) => {
277
492
  /** @type {Array<CustomerMatchRecord>} */
278
493
  const userIds = lines.map((line) => JSON.parse(line));
494
+ /** @const {BatchResult} */ const batchResult = {
495
+ result: true,
496
+ numberOfLines: lines.length,
497
+ };
279
498
  try {
280
- return await this.uploadUserDataToUserList(userIds,
499
+ const response = await this.uploadUserDataToUserList(userIds,
281
500
  customerMatchConfig);
501
+ this.logger.debug(`Customer Match upload batch[${batchId}]`, response);
502
+ return batchResult;
282
503
  } catch (error) {
283
504
  this.logger.error(
284
- `Error in getUploadCustomerMatchFn in batchId: ${batchId}`, error);
285
- return false;
505
+ `Error in Customer Match upload batch[${batchId}]`, error);
506
+ this.updateBatchResultWithError_(batchResult, error, lines, 2);
507
+ return batchResult;
286
508
  }
287
509
  }
288
510
  }
289
511
 
290
512
  /**
291
513
  * Uploads a user data to a user list (aka customer match).
292
- * @see https://developers.google.com/google-ads/api/reference/rpc/v7/UserDataService
293
- * @see https://developers.google.com/google-ads/api/reference/rpc/v7/UserDataOperation
294
- * @see https://developers.google.com/google-ads/api/reference/rpc/v7/UserData
295
- * @see https://developers.google.com/google-ads/api/reference/rpc/v7/UserIdentifier
296
- * @see https://developers.google.com/google-ads/api/reference/rpc/v7/CustomerMatchUserListMetadata
514
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserDataService
515
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserDataOperation
516
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserData
517
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserIdentifier
518
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/CustomerMatchUserListMetadata
297
519
  * Please note: The UserDataService has a limit of 10 UserDataOperations
298
520
  * and 100 user IDs per request
299
521
  * @see https://developers.google.com/google-ads/api/docs/migration/user-data-service#rate_limits
@@ -302,7 +524,7 @@ class GoogleAds {
302
524
  * customer_id, login_customer_id, list_id, list_type which can be one of the following
303
525
  * hashed_email, hashed_phone_number, mobile_id, third_party_user_id or address_info and
304
526
  * operation which can be either 'create' or 'remove'
305
- * @return {!Promise<boolean>}
527
+ * @return {!Promise<UploadUserDataResponse>}
306
528
  */
307
529
  async uploadUserDataToUserList(customerMatchRecords, customerMatchConfig) {
308
530
  const customerId = customerMatchConfig.customer_id.replace(/-/g, '');
@@ -323,16 +545,15 @@ class GoogleAds {
323
545
  customer_match_user_list_metadata: metadata,
324
546
  });
325
547
  const response = await customer.userData.uploadUserData(request);
326
- this.logger.debug('Uploaded CM users:', response);
327
- return true;
548
+ return response;
328
549
  }
329
550
 
330
551
  /**
331
552
  * Builds a list of UserDataOperations.
332
553
  * Since v6 you can set a user_attribute
333
- * @see https://developers.google.com/google-ads/api/reference/rpc/v7/UserData
334
- * @see https://developers.google.com/google-ads/api/reference/rpc/v7/UserIdentifier
335
- * @see https://developers.google.com/google-ads/api/reference/rpc/v7/UserDataOperation
554
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserData
555
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserIdentifier
556
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserDataOperation
336
557
  * @param {string} operationType either 'create' or 'remove'
337
558
  * @param {Array<CustomerMatchRecord>} customerMatchRecords userIds
338
559
  * @param {string} userListType One of the following hashed_email, hashed_phone_number,
@@ -351,7 +572,7 @@ class GoogleAds {
351
572
 
352
573
  /**
353
574
  * Creates CustomerMatchUserListMetadata.
354
- * @see https://developers.google.com/google-ads/api/reference/rpc/v7/CustomerMatchUserListMetadata
575
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/CustomerMatchUserListMetadata
355
576
  * @param {string} customerId part of the ResourceName to be mutated
356
577
  * @param {string} userListId part of the ResourceName to be mutated
357
578
  * @return {!CustomerMatchUserListMetadata}
@@ -383,6 +604,43 @@ class GoogleAds {
383
604
 
384
605
  }
385
606
 
607
+ /**
608
+ * Returns a conversion object based the given config and line data.
609
+ * @param {string} line A JSON string of a conversion data.
610
+ * @param {ClickConversionConfig} config Default click conversion params
611
+ * @param {string} customerId
612
+ * @return {object} A conversion
613
+ */
614
+ const buildClickConversionFromLine = (line, config, customerId) => {
615
+ const {customVariables, user_identifier_source} = config;
616
+ const record = JSON.parse(line);
617
+ const conversion = lodash.merge(lodash.pick(config, PICKED_PROPERTIES),
618
+ lodash.pick(record, PICKED_PROPERTIES));
619
+ if (customVariables) {
620
+ const tags = Object.keys(customVariables);
621
+ conversion.custom_variables = tags.map((tag) => {
622
+ return {
623
+ conversion_custom_variable:
624
+ `customers/${customerId}/conversionCustomVariables/${customVariables[tag]}`,
625
+ value: record[tag],
626
+ };
627
+ });
628
+ }
629
+ const user_identifiers = [];
630
+ IDENTIFIERS.forEach((identifier) => {
631
+ if (record[identifier]) {
632
+ user_identifiers.push({
633
+ user_identifier_source,
634
+ [identifier]: record[identifier],
635
+ });
636
+ }
637
+ });
638
+ if (user_identifiers.length > 0) {
639
+ conversion.user_identifiers = user_identifiers;
640
+ }
641
+ return conversion;
642
+ }
643
+
386
644
  module.exports = {
387
645
  ClickConversionConfig,
388
646
  CustomerMatchRecord,
@@ -390,4 +648,5 @@ module.exports = {
390
648
  GoogleAds,
391
649
  ReportQueryConfig,
392
650
  GoogleAdsField,
651
+ buildClickConversionFromLine,
393
652
  };
package/src/apis/index.js CHANGED
@@ -110,3 +110,13 @@ exports.adsdatahub = require('./ads_data_hub.js');
110
110
  * }}
111
111
  */
112
112
  exports.measurementprotocolga4 = require('./measurement_protocol_ga4.js');
113
+
114
+ /**
115
+ * APIs integration class for YouTube.
116
+ * @const {{
117
+ * YouTube:!YouTube,
118
+ * ListChannelsConfig: !ListChannelsConfig,
119
+ * ListVideosConfig: !ListVideosConfig,
120
+ * }}
121
+ */
122
+ exports.youtube = require('./youtube.js');
@@ -69,18 +69,14 @@ class MeasurementProtocol {
69
69
  * @return {!Promise<BatchResult>}
70
70
  */
71
71
  return async (lines, batchId) => {
72
- const payload =
73
- lines
74
- .map((line) => {
75
- const record = JSON.parse(line);
76
- const hit = Object.assign({}, config, record);
77
- return Object.keys(hit)
78
- .map((key) => {
79
- return `${key}=${encodeURIComponent(hit[key])}`;
80
- })
81
- .join('&');
82
- })
83
- .join('\n');
72
+ const payload = lines.map((line) => {
73
+ const record = JSON.parse(line);
74
+ const hit = Object.assign({}, config, record);
75
+ return Object.keys(hit).map(
76
+ (key) => `${key}=${encodeURIComponent(hit[key])}`)
77
+ .join('&');
78
+ })
79
+ .join('\n');
84
80
  // In debug mode, the path is fixed to '/debug/collect'.
85
81
  const path = (this.debugMode) ? '/debug/collect' : '/batch';
86
82
  const requestOptions = {
@@ -112,24 +108,48 @@ class MeasurementProtocol {
112
108
  if (!this.debugMode) {
113
109
  batchResult.result = true;
114
110
  } else {
115
- const failedHits = [];
116
- const failedReasons = new Set();
117
- response.data.hitParsingResult.forEach((result, index) => {
118
- if (!result.valid) {
119
- failedHits.push(lines[index]);
120
- result.parserMessage.forEach(({description}) => {
121
- failedReasons.add(description);
122
- });
123
- }
124
- });
125
- batchResult.result = failedHits.length === 0;
126
- batchResult.failedLines = failedHits;
127
- batchResult.errors = Array.from(failedReasons);
111
+ this.extraFailedLines_(batchResult, response.data.hitParsingResult,
112
+ lines);
128
113
  }
129
114
  return batchResult;
130
115
  };
131
116
  };
132
117
 
118
+ /**
119
+ * Extras failed lines based on the hitParsingResult, see:
120
+ * https://developers.google.com/analytics/devguides/collection/protocol/v1/validating-hits
121
+ *
122
+ * Note, only in 'debug' mode, Google Analytics will return this part of data.
123
+ *
124
+ * @param {!BatchResult} batchResult
125
+ * @param {!Array<!Object>} hitParsingResults
126
+ * @param {!Array<string>} lines The original input data.
127
+ * @private
128
+ */
129
+ extraFailedLines_(batchResult, hitParsingResults, lines) {
130
+ batchResult.failedLines = [];
131
+ batchResult.groupedFailed = {};
132
+ const errors = new Set();
133
+ hitParsingResults.forEach((result, index) => {
134
+ if (!result.valid) {
135
+ const failedLine = lines[index];
136
+ batchResult.failedLines.push(failedLine);
137
+ result.parserMessage.forEach(({description: error, messageType}) => {
138
+ this.logger.info(`[${messageType}]: ${error} for ${failedLine}`);
139
+ if (messageType === 'ERROR') {
140
+ errors.add(error);
141
+ const groupedFailed = batchResult.groupedFailed[error] || [];
142
+ groupedFailed.push(failedLine);
143
+ if (groupedFailed.length === 1) {
144
+ batchResult.groupedFailed[error] = groupedFailed;
145
+ }
146
+ }
147
+ });
148
+ }
149
+ });
150
+ batchResult.result = batchResult.failedLines.length === 0;
151
+ batchResult.errors = Array.from(errors);
152
+ }
133
153
  }
134
154
 
135
155
  module.exports = {MeasurementProtocol};
@@ -20,6 +20,7 @@
20
20
  'use strict';
21
21
 
22
22
  const {request} = require('gaxios');
23
+ const lodash = require('lodash');
23
24
  const {
24
25
  getLogger,
25
26
  SendSingleBatch,
@@ -93,7 +94,11 @@ class MeasurementProtocolGA4 {
93
94
  */
94
95
  return async (lines, batchId) => {
95
96
  const line = lines[0]; // Each request contains one record only.
96
- const hit = Object.assign({}, config.requestBody, JSON.parse(line));
97
+ if (lines.length > 1) {
98
+ this.logger.warn(
99
+ "Only one line data expected. Will only send the first line.");
100
+ }
101
+ const hit = lodash.merge({}, config.requestBody, JSON.parse(line));
97
102
 
98
103
  const requestOptions = {
99
104
  method: 'POST',
@@ -135,6 +140,7 @@ class MeasurementProtocolGA4 {
135
140
  batchResult.failedLines = lines;
136
141
  batchResult.errors = response.data.validationMessages.map(
137
142
  ({description}) => description);
143
+ batchResult.groupedFailed = {[batchResult.errors.join()]: [lines[0]]};
138
144
  }
139
145
  }
140
146
  return batchResult;