@google-cloud/nodejs-common 2.0.4-alpha → 2.0.6-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.
@@ -0,0 +1,1550 @@
1
+ // Copyright 2024 Google Inc.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ /**
16
+ * @fileoverview Google Ads API Wrapper based on the NodeJS library complied
17
+ * from the Bazel generated files.
18
+ * @see https://github.com/googleapis/googleapis
19
+ * @see https://github.com/lushu/googleads-nodejs
20
+ */
21
+ 'use strict';
22
+
23
+ const { Transform } = require('stream');
24
+ const lodash = require('lodash');
25
+
26
+ const {
27
+ ConversionAdjustmentUploadServiceClient,
28
+ ConversionUploadServiceClient,
29
+ GoogleAdsServiceClient,
30
+ GoogleAdsFieldServiceClient,
31
+ OfflineUserDataJobServiceClient,
32
+ UserDataServiceClient,
33
+ UserListServiceClient,
34
+ protos: { google: { ads: { googleads } } },
35
+ } = require('google-ads-nodejs-client');
36
+ const {
37
+ common: {
38
+ Consent,
39
+ CustomerMatchUserListMetadata,
40
+ StoreSalesMetadata,
41
+ TransactionAttribute,
42
+ UserAttribute,
43
+ UserData,
44
+ UserIdentifier,
45
+ },
46
+ resources: {
47
+ GoogleAdsField,
48
+ OfflineUserDataJob,
49
+ UserList,
50
+ },
51
+ services: {
52
+ AddOfflineUserDataJobOperationsRequest,
53
+ AddOfflineUserDataJobOperationsResponse,
54
+ CallConversion,
55
+ ClickConversion,
56
+ ConversionAdjustment,
57
+ CreateOfflineUserDataJobRequest,
58
+ CustomVariable,
59
+ GoogleAdsRow,
60
+ MutateUserListsRequest,
61
+ MutateUserListsResponse,
62
+ OfflineUserDataJobOperation,
63
+ RunOfflineUserDataJobRequest,
64
+ SearchGoogleAdsFieldsRequest,
65
+ SearchGoogleAdsRequest,
66
+ SearchGoogleAdsResponse,
67
+ UploadCallConversionsRequest,
68
+ UploadCallConversionsResponse,
69
+ UploadClickConversionsRequest,
70
+ UploadClickConversionsResponse,
71
+ UploadConversionAdjustmentsRequest,
72
+ UploadConversionAdjustmentsResponse,
73
+ UploadUserDataRequest,
74
+ UploadUserDataResponse,
75
+ UserDataOperation,
76
+ },
77
+ errors: {
78
+ GoogleAdsError,
79
+ GoogleAdsFailure,
80
+ },
81
+ enums: {
82
+ ConsentStatusEnum: { ConsentStatus },
83
+ OfflineUserDataJobFailureReasonEnum: { OfflineUserDataJobFailureReason },
84
+ OfflineUserDataJobTypeEnum: { OfflineUserDataJobType },
85
+ OfflineUserDataJobStatusEnum: { OfflineUserDataJobStatus },
86
+ UserIdentifierSourceEnum: { UserIdentifierSource },
87
+ UserListMembershipStatusEnum: { UserListMembershipStatus },
88
+ UserListTypeEnum: { UserListType },
89
+ CustomerMatchUploadKeyTypeEnum: { CustomerMatchUploadKeyType },
90
+ },
91
+ } = googleads.v15;
92
+
93
+ const AuthClient = require('./auth_client.js');
94
+ const {
95
+ getLogger,
96
+ BatchResult,
97
+ extractObject,
98
+ changeObjectNamingFromSnakeToLowerCamel,
99
+ } = require('../components/utils.js');
100
+
101
+ /** @type {!ReadonlyArray<string>} */
102
+ const API_SCOPES = Object.freeze(['https://www.googleapis.com/auth/adwords']);
103
+
104
+ /**
105
+ * List of properties that will be taken from the data file as elements of a
106
+ * conversion or a conversion adjustment.
107
+ * @const {string: Array<string>}
108
+ */
109
+ const CONVERSION_FIELDS = {};
110
+ /**
111
+ * @see CallConversion
112
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/CallConversion
113
+ */
114
+ CONVERSION_FIELDS.CALL = [
115
+ 'callerId',
116
+ 'consent',
117
+ 'callStartDateTime',
118
+ 'conversionAction',
119
+ 'conversionDateTime',
120
+ 'conversionValue',
121
+ 'currencyCode',
122
+ ];
123
+ /**
124
+ * @see ClickConversion
125
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/ClickConversion
126
+ */
127
+ CONVERSION_FIELDS.CLICK = [
128
+ 'gbraid',
129
+ 'wbraid',
130
+ 'gclid',
131
+ 'externalAttributionData',
132
+ 'conversionEnvironment',
133
+ 'consent',
134
+ 'gclid',
135
+ 'conversionAction',
136
+ 'conversionDateTime',
137
+ 'conversionValue',
138
+ 'currencyCode',
139
+ 'orderId',
140
+ ];
141
+ /**
142
+ * @see ConversionAdjustment
143
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/ConversionAdjustment
144
+ */
145
+ CONVERSION_FIELDS.ADJUSTMENT = [
146
+ 'gclidDateTimePair',
147
+ 'adjustmentType',
148
+ 'restatementValue',
149
+ 'orderId',
150
+ 'conversionAction',
151
+ 'adjustmentDateTime',
152
+ 'userAgent',
153
+ ];
154
+
155
+ /**
156
+ * Additional attributes in user data for store sales data or customer match.
157
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserData
158
+ * @type {Array<string>}
159
+ */
160
+ const USERDATA_ADDITIONAL_ATTRIBUTES = [
161
+ 'transactionAttribute', // Attribute of the store sales transaction.
162
+ 'userAttribute', // Only be used with CUSTOMER_MATCH_WITH_ATTRIBUTES job type.
163
+ 'consent',
164
+ ];
165
+
166
+ /**
167
+ * Kinds of UserIdentifier for different services.
168
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserIdentifier
169
+ * @see UserIdentifier
170
+ * @type {Array<string>}
171
+ */
172
+ const IDENTIFIERS = {};
173
+ IDENTIFIERS.CUSTOMER_MATCH = [
174
+ 'hashedEmail',
175
+ 'hashedPhoneNumber',
176
+ 'mobileId',
177
+ 'thirdPartyUserId',
178
+ 'addressInfo',
179
+ ];
180
+ IDENTIFIERS.STORE_SALES = [
181
+ 'hashedEmail',
182
+ 'hashedPhoneNumber',
183
+ 'thirdPartyUserId',
184
+ 'addressInfo',
185
+ ];
186
+ IDENTIFIERS.CLICK_CONVERSION = [
187
+ 'hashedEmail',
188
+ 'hashedPhoneNumber',
189
+ ];
190
+ IDENTIFIERS.CONVERSION_ADJUSTMENT = [
191
+ 'hashedEmail',
192
+ 'hashedPhoneNumber',
193
+ 'addressInfo',
194
+ ];
195
+
196
+ /**
197
+ * Maximum number of user identifiers in single UserData.
198
+ * @see https://ads-developers.googleblog.com/2021/10/userdata-enforcement-in-google-ads-api.html
199
+ * @see https://developers.google.com/google-ads/api/reference/rpc/v15/ClickConversion
200
+ * @type {number}
201
+ */
202
+ const MAX_IDENTIFIERS = {
203
+ USER_DATA: 20,
204
+ CONVERSION: 5,
205
+ };
206
+
207
+ /**
208
+ * Configuration for uploading click conversions, call converions or conversion
209
+ * adjustments for Google Ads, includes:
210
+ * gclid, conversionAction, conversionDateTime, conversionValue,
211
+ * currencyCode, orderId, externalAttributionData,
212
+ * callerId, callStartDateTime,
213
+ * adjustmentType, adjustmentDateTime, userAgent, gclidDateTimePair, etc.
214
+ * @see CONVERSION_FIELDS
215
+ *
216
+ * Other properties that will be used to build the conversions but not picked by
217
+ * the value directly including:
218
+ * 1. 'userIdentifierSource', source of the user identifier. If there is user
219
+ * identifiers information in the conversion, this property should be set as
220
+ * 'FIRST_PARTY'.
221
+ * @see IDENTIFIERS
222
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserIdentifier?hl=en
223
+ * 2. 'customVariableTags', the tags of conversion custom variables. To upload
224
+ * custom variables, 'conversionCustomVariableId' is required rather than the
225
+ * 'tag'. So the invoker is expected to use the function
226
+ * 'getConversionCustomVariableId' to get the ids and pass in as a
227
+ * map(customVariables) of <tag, id> pairs before uploading conversions.
228
+ *
229
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/ClickConversion
230
+ * @typedef {{
231
+ * externalAttributionData: (GoogleAdsApi.ExternalAttributionData|undefined),
232
+ * cartData: (object|undefined),
233
+ * gclid: (string|undefined),
234
+ * callerId: (string|undefined),
235
+ * callStartDateTime: (string|undefined),
236
+ * conversionAction: string,
237
+ * conversionDateTime: string,
238
+ * conversionValue: number,
239
+ * currencyCode:(string|undefined),
240
+ * orderId: (string|undefined),
241
+ * adjustmentType: (string|undefined),
242
+ * adjustmentDateTime: (!ConversionAdjustmentType|undefined),
243
+ * userAgent: (string|undefined),
244
+ * userIdentifierSource:(!UserIdentifierSource|undefined),
245
+ * customVariableTags:(!Array<string>|undefined),
246
+ * customVariables:(!Object<string,string>|undefined),
247
+ * consent: (!Consent),
248
+ * }}
249
+ */
250
+ let ConversionConfig;
251
+
252
+ /**
253
+ * Configuration for uploading customer match to Google Ads, includes:
254
+ * customerId, loginCustomerId, listId and operation.
255
+ * If audience listId is not present, 'listName' and 'uploadKeyType' need to
256
+ * be there so they can be used to create a customer match user list.
257
+ * operation must be one of 'create' or 'remove'.
258
+ * Should not include `userIdentifierSource` based on:
259
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserIdentifier
260
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserDataOperation
261
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/CustomerMatchUploadKeyTypeEnum.CustomerMatchUploadKeyType
262
+ * @typedef {{
263
+ * customerId: (string|number),
264
+ * loginCustomerId: (string|number),
265
+ * listId: (string|undefined),
266
+ * listName: (string|undefined),
267
+ * uploadKeyType: ('CONTACT_INFO'|'CRM_ID'|'MOBILE_ADVERTISING_ID'|undefined),
268
+ * operation: ('create'|'remove'),
269
+ * consent: (!Consent),
270
+ * }}
271
+ */
272
+ let CustomerMatchConfig;
273
+
274
+ /**
275
+ * Configuration for offline user data job, includes:
276
+ * customerId, loginCustomerId, listId, operation and type.
277
+ * 'operation' should be one of the two: 'create' or 'remove',
278
+ * 'type' is OfflineUserDataJobType, it can be 'CUSTOMER_MATCH_USER_LIST',
279
+ * 'CUSTOMER_MATCH_WITH_ATTRIBUTES' or 'STORE_SALES_UPLOAD_FIRST_PARTY'.
280
+ * For job type 'CUSTOMER_MATCH_USER_LIST', if `listId` is not present,
281
+ * 'listName' and 'uploadKeyType' need to be there so they can be used to
282
+ * create a customer match user list.
283
+ * For job type 'CUSTOMER_MATCH_WITH_ATTRIBUTES', 'user_attribute' can be used
284
+ * to store shared additional user attributes.
285
+ * For job type 'STORE_SALES_UPLOAD_FIRST_PARTY', `storeSalesMetadata` is
286
+ * required to offer StoreSalesMetadata. Besides that, for the store sales data,
287
+ * common data (e.g. `currencyCode`, `conversionAction`) in
288
+ * `transactionAttribute` can be put here as well.
289
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/OfflineUserDataJob
290
+ * @typedef {{
291
+ * customerId: (string|number),
292
+ * loginCustomerId: (string|number),
293
+ * listId: (string|undefined),
294
+ * listName: (string|undefined),
295
+ * uploadKeyType: ('CONTACT_INFO'|'CRM_ID'|'MOBILE_ADVERTISING_ID'|undefined),
296
+ * operation: ('create'|'remove'),
297
+ * type: !OfflineUserDataJobType,
298
+ * storeSalesMetadata: (undefined|StoreSalesMetadata),
299
+ * transactionAttribute: (undefined|TransactionAttribute),
300
+ * userAttribute: (undefined|UserAttribute),
301
+ * userIdentifierSource: (!UserIdentifierSource|undefined),
302
+ * consent: (!Consent),
303
+ * }}
304
+ */
305
+ let OfflineUserDataJobConfig;
306
+
307
+ /**
308
+ * Configuration for uploading customer match data for Google Ads.
309
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserIdentifier
310
+ * @typedef {{
311
+ * hashedEmail: (string|Array<string>|undefined),
312
+ * hashedPhoneNumber: (string|Array<string>|undefined),
313
+ * mobileId: (string|Array<string>|undefined),
314
+ * thirdPartyUserId: (string|Array<string>|undefined),
315
+ * addressInfo: (GoogleAdsApi.OfflineUserAddressInfo|undefined),
316
+ * userIdentifierSource: (!UserIdentifierSource|undefined),
317
+ * }}
318
+ */
319
+ let CustomerMatchRecord;
320
+
321
+ /**
322
+ * Google Ads API class based on Google Ads API library.
323
+ */
324
+ class GoogleAdsApi {
325
+ /**
326
+ * Note: Rate limits is set by the access level of Developer token.
327
+ * @param {string} developerToken Developer token to access the API.
328
+ * @param {boolean=} debugMode This is used to set ONLY validate conversions
329
+ * but not real uploading.
330
+ * @param {!Object<string,string>=} env The environment object to hold env
331
+ * variables.
332
+ */
333
+ constructor(developerToken, debugMode = false, env = process.env) {
334
+ this.developerToken = developerToken;
335
+ this.debugMode = debugMode;
336
+ this.authClient = new AuthClient(API_SCOPES, env);
337
+ this.logger = getLogger('API.ADS.N');
338
+ this.logger.info(
339
+ `Init ${this.constructor.name} with Debug Mode?`, this.debugMode);
340
+ }
341
+
342
+ /**
343
+ * Gets a Google Ads report based on a Google Ads Query Language(GAQL) query
344
+ * with pagination automatically handled. It returns an array of
345
+ * `GoogleAdsRow` without other fields in the object
346
+ * `SearchGoogleAdsResponse`, e.g.`fieldMask`.
347
+ * The `GoogleAdsRow` objects have `null` value for those unselected fields.
348
+ * The enum fields in the query are present as index number.
349
+ * @param {string} customerId
350
+ * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
351
+ * @param {string} query A Google Ads Query Language query.
352
+ * @return {!Array<!GoogleAdsRow>}
353
+ */
354
+ async getReport(customerId, loginCustomerId, query) {
355
+ const request = new SearchGoogleAdsRequest({
356
+ query,
357
+ customerId: this.getCleanCid_(customerId),
358
+ });
359
+ return this.getReport_(request, loginCustomerId, true);
360
+ }
361
+
362
+ /**
363
+ * Gets a page of Google Ads report based on a GAQL query.
364
+ * It returns a `SearchGoogleAdsResponse` object with the property `results`
365
+ * which contains an array of `GoogleAdsRow`. The returned object also
366
+ * contains `nextPageToken`.
367
+ * Note, the `GoogleAdsRow` objects have `null` value for those unselected
368
+ * fields. The `fieldMask` in the response CAN be used to clear the result
369
+ * objects.
370
+ * The enum fields in the query are present as index number.
371
+ * @param {string} customerId
372
+ * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
373
+ * @param {string} query A Google Ads Query Language query.
374
+ * @param {object=} options Options for `SearchGoogleAdsRequest`. It can
375
+ * contain `pageSize` whose default value is 10000 and `pageToken` if it
376
+ * is going to get the next page.
377
+ * @return {!SearchGoogleAdsResponse}
378
+ */
379
+ async getPaginatedReport(customerId, loginCustomerId, query, options = {}) {
380
+ const request = new SearchGoogleAdsRequest(
381
+ Object.assign({
382
+ query,
383
+ customerId: this.getCleanCid_(customerId),
384
+ pageSize: 10000,
385
+ }, options)
386
+ );
387
+ return this.getReport_(request, loginCustomerId);
388
+ }
389
+
390
+ /**
391
+ * Gets Google Ads report based on the parameters. The Google Ads API returns
392
+ * an array with three element:
393
+ * 1. Array<GoogleAdsRow>
394
+ * 2. SearchGoogleAdsRequest | null,
395
+ * 3. SearchGoogleAdsResponse
396
+ * When the `autoPaginate` is set to be true, only the first element is
397
+ * available, so the returned value is an array of GoogleAdsRow, otherwise
398
+ * the returned value is a `SearchGoogleAdsResponse` object.
399
+ * @param {!SearchGoogleAdsRequest} request
400
+ * @param {string} loginCustomerId
401
+ * @param {boolean=} autoPaginate
402
+ * @return {!Array<(!GoogleAdsRow>|!SearchGoogleAdsResponse)}
403
+ * @private
404
+ */
405
+ async getReport_(request, loginCustomerId, autoPaginate = false) {
406
+ const client = await this.getGoogleAdsServiceClient_();
407
+ const callOptions = this.getCallOptions_(loginCustomerId);
408
+ callOptions.autoPaginate = autoPaginate;
409
+ const response = await client.search(request, callOptions);
410
+ const result = response[autoPaginate ? 0 : 2];
411
+ return result;
412
+ }
413
+
414
+ //TODO: Test a big report see how the event 'data' is triggered
415
+ /**
416
+ * Gets stream report of a given Customer account. The stream will send
417
+ * `SearchGoogleAdsResponse` objects with the event 'data'.
418
+ * @param {string} customerId
419
+ * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
420
+ * @param {string} query
421
+ * @return {!StreamProxy}
422
+ */
423
+ async streamReport(customerId, loginCustomerId, query) {
424
+ const client = await this.getGoogleAdsServiceClient_();
425
+ const request = new SearchGoogleAdsRequest({
426
+ query,
427
+ customerId: this.getCleanCid_(customerId),
428
+ });
429
+ const callOptions = this.getCallOptions_(loginCustomerId);
430
+ const response = await client.searchStream(request, callOptions);
431
+ return response;
432
+ }
433
+
434
+ /**
435
+ * Gets the report with only selected fields.
436
+ * Based on the `fieldMask` in the `SearchGoogleAdsResponse` to filter out
437
+ * selected fields of the report and returns the `GoogleAdsRows` in the
438
+ * `results` in JSON format strings with the delimit of a line breaker.
439
+ * @param {string} customerId
440
+ * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
441
+ * @param {string} query
442
+ * @return {!Promise<string>}
443
+ */
444
+ async cleanedStreamReport(customerId, loginCustomerId, query) {
445
+ const cleanReportStream = new Transform({
446
+ writableObjectMode: true,
447
+ transform(chunk, encoding, callback) {
448
+ const { fieldMask: { paths } } = chunk;
449
+ const extractor = extractObject(paths);
450
+ // Add a line break after each chunk to keep files in proper format.
451
+ const data = chunk.results.map(extractor).map(JSON.stringify).join('\n')
452
+ + '\n';
453
+ callback(null, data);
454
+ }
455
+ });
456
+ const stream = await this.streamReport(customerId, loginCustomerId, query);
457
+ return stream.pipe(cleanReportStream);
458
+ }
459
+
460
+ /**
461
+ * Returns resources information from Google Ads API. see:
462
+ * https://developers.google.com/google-ads/api/docs/concepts/field-service
463
+ * Note, it looks like this function doesn't check the CID, just using
464
+ * developer token and OAuth.
465
+ * @param {string|number} loginCustomerId Login customer account ID.
466
+ * @param {Array<string>} adFields Array of Ad fields.
467
+ * @param {Array<string>} metadata Select fields, default values are:
468
+ * name, data_type, is_repeated, type_url.
469
+ * @return {!Promise<!Array<GoogleAdsField>>}
470
+ */
471
+ async searchMetaData(loginCustomerId, adFields, metadata = [
472
+ 'name', 'data_type', 'is_repeated', 'type_url',]) {
473
+ const client = await this.getGoogleAdsFieldServiceClient_();
474
+ const selectClause = metadata.join(',');
475
+ const fields = adFields.join('","');
476
+ const query = `SELECT ${selectClause} WHERE name IN ("${fields}")`;
477
+ const request = new SearchGoogleAdsFieldsRequest({ query });
478
+ const callOptions = this.getCallOptions_(loginCustomerId);
479
+ const [results] = await client.searchGoogleAdsFields(request, callOptions);
480
+ return results;
481
+ }
482
+
483
+ /**
484
+ * Returns the function to send out a request to Google Ads API with a batch
485
+ * of call conversions.
486
+ * @param {string} customerId
487
+ * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
488
+ * @param {!ConversionConfig} adsConfig Default call conversion params
489
+ * @return {!SendSingleBatch} Function which can send a batch of hits to
490
+ * Google Ads API.
491
+ */
492
+ getUploadCallConversionFn(customerId, loginCustomerId, adsConfig) {
493
+ return this.getUploadConversionFnBase_(customerId, loginCustomerId,
494
+ adsConfig, 'uploadCallConversions', 'callerId');
495
+ }
496
+
497
+ /**
498
+ * Returns the function to send out a request to Google Ads API with a batch
499
+ * of click conversions.
500
+ * @param {string} customerId
501
+ * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
502
+ * @param {!ConversionConfig} adsConfig Default click conversion params
503
+ * @return {!SendSingleBatch} Function which can send a batch of hits to
504
+ * Google Ads API.
505
+ */
506
+ getUploadClickConversionFn(customerId, loginCustomerId, adsConfig) {
507
+ return this.getUploadConversionFnBase_(customerId, loginCustomerId,
508
+ adsConfig, 'uploadClickConversions', 'gclid');
509
+ }
510
+
511
+ /**
512
+ * Returns the function to send out a request to Google Ads API with a batch
513
+ * of conversion adjustments.
514
+ * @param {string} customerId
515
+ * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
516
+ * @param {!ConversionConfig} adsConfig Default conversion adjustments
517
+ * params.
518
+ * @return {!SendSingleBatch} Function which can send a batch of hits to
519
+ * Google Ads API.
520
+ */
521
+ getUploadConversionAdjustmentFn(customerId, loginCustomerId, adsConfig) {
522
+ return this.getUploadConversionFnBase_(customerId, loginCustomerId,
523
+ adsConfig, 'uploadConversionAdjustments', 'orderId');
524
+ }
525
+
526
+ /**
527
+ * Returns the function to send call conversions, click conversions or
528
+ * conversion adjustment (enhanced conversions).
529
+ *
530
+ * Google Ads API conversion related services (ConversionUploadService,
531
+ * ConversionAdjustmentUploadService) support `partial_failure` which will
532
+ * return `partialFailureError`(Status) when 'some kinds of errors' happen,
533
+ * e.g. wrong conversion Id, wrong gclid, etc.
534
+ * `Status` has a property `message` that has the general information of the
535
+ * error, however, the detailed information (e.g. the failed conversions and
536
+ * their reasons) lies in the property named `datails` (an array of
537
+ * `GoogleAdsFailure`). Each `GoogleAdsFailure` is related to a failed
538
+ * conversion. The function `extraFailedLines_` is used to extract the
539
+ * details.
540
+ *
541
+ * There is also another kind of error that will be throw directly. Sometimes,
542
+ * this kind of error is a wrapped `GoogleAdsFailure`, e.g. wrong CID.
543
+ * The function `updateBatchResultWithError` for this kind of error.
544
+ * @see extraFailedLines_
545
+ * @see updateBatchResultWithError
546
+ * @see https://developers.google.com/google-ads/api/reference/rpc/google.rpc#google.rpc.Status
547
+ * @param {string} customerId
548
+ * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
549
+ * @param {!ConversionConfig} conversionConfig Default conversion parameters.
550
+ * @param {string} functionName The name of sending converions function, could
551
+ * be `uploadClickConversions`, `uploadCallConversions` or
552
+ * `uploadConversionAdjustments`.
553
+ * @param {string} propertyForDebug The name of property for debug info.
554
+ * @return {!SendSingleBatch} Function which can send a batch of hits to
555
+ * Google Ads API.
556
+ * @private
557
+ */
558
+ getUploadConversionFnBase_(customerId, loginCustomerId, conversionConfig,
559
+ functionName, propertyForDebug) {
560
+ /** @type {!ConversionConfig} */
561
+ const adsConfig = this.getCamelConfig_(conversionConfig);
562
+ adsConfig.customerId = this.getCleanCid_(customerId);
563
+ adsConfig.loginCustomerId = this.getCleanCid_(loginCustomerId);
564
+ /**
565
+ * Sends a batch of hits to Google Ads API.
566
+ * @param {!Array<string>} lines Data for single request. It should be
567
+ * guaranteed that it doesn't exceed quota limitation.
568
+ * @param {string} batchId The tag for log.
569
+ * @return {!BatchResult}
570
+ */
571
+ return async (lines, batchId) => {
572
+ /** @const {BatchResult} */
573
+ const batchResult = {
574
+ result: true,
575
+ numberOfLines: lines.length,
576
+ };
577
+ try {
578
+ const response = await this[functionName](lines, adsConfig);
579
+ const { results, partialFailureError: failed, jobId } = response;
580
+ if (this.logger.isDebugEnabled()) {
581
+ const id = results.map((conversion) => conversion[propertyForDebug]);
582
+ this.logger.debug(`Uploaded ${propertyForDebug}:`, id);
583
+ }
584
+ if (failed) {
585
+ this.logger.info(`Job[${jobId}] partialFailureError:`, failed.message);
586
+ const failures = failed.details.map(
587
+ ({ value }) => GoogleAdsFailure.decode(value));
588
+ this.extraFailedLines_(batchResult, failures, lines, 0);
589
+ }
590
+ return batchResult;
591
+ } catch (error) {
592
+ this.logger.error(
593
+ `Error in ${functionName} batch: ${batchId}`, error);
594
+ this.updateBatchResultWithError(batchResult, error, lines, 0);
595
+ return batchResult;
596
+ }
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Updates the BatchResult based on errors.
602
+ *
603
+ * There are 2 types of errors here:
604
+ * 1. Normal JavaScript Error object. It happens outside of the communication
605
+ * with Google Ads API and fails the whole process, so there is no detailed
606
+ * failed lines.
607
+ * 2. GoogleAdsFailure (which is wrapped in error.metadata in this library).
608
+ * GoogleAdsFailure is a Google Ads' own error object which has an array of
609
+ * GoogleAdsError (property name 'errors'). GoogleAdsError contains
610
+ * the detailed failed data if it is a "partial failure". For example, a wrong
611
+ * encoded user identifier is a "partial failure", while a wrong user list id
612
+ * is not.
613
+ * GoogleAdsFailure: https://developers.google.com/google-ads/api/reference/rpc/latest/GoogleAdsFailure
614
+ * GoogleAdsError: https://developers.google.com/google-ads/api/reference/rpc/latest/GoogleAdsError
615
+ *
616
+ * For Customer Match data uploading, there is not partial failure, so the
617
+ * result can be either succeeded or a thrown error. The thrown error will be
618
+ * used to build the returned result here.
619
+ * For Conversions uploading (partial failure enabled), if there is an error
620
+ * fails the whole process, the error will also be thrown and handled here.
621
+ * Otherwise, the errors will be wrapped in the response as the property named
622
+ * 'partial_failure_error' which contains an array of GoogleAdsFailure. This
623
+ * kind of failure doesn't fail the process, while line-errors can be
624
+ * extracted from it.
625
+ * For more information, see the function `extraFailedLines_`.
626
+ *
627
+ * An example of 'GoogleAdsFailure' is:
628
+ * GoogleAdsFailure {
629
+ * errors: [
630
+ * GoogleAdsError {
631
+ * error_code: ErrorCode { offline_user_data_job_error: 25 },
632
+ * message: 'The SHA256 encoded value is malformed.',
633
+ * location: ErrorLocation {
634
+ * fieldPathElements: [
635
+ * FieldPathElement { fieldName: 'operations', index: 0 },
636
+ * FieldPathElement { fieldName: 'create' },
637
+ * FieldPathElement { fieldName: 'user_identifiers', index: 0 },
638
+ * FieldPathElement { fieldName: 'hashed_email' }
639
+ * ]
640
+ * }
641
+ * }
642
+ * ],
643
+ * requestId: 'xxxxxxxxxxxxxxx'
644
+ * }
645
+ *
646
+ * @param {!BatchResult} batchResult
647
+ * @param {(!GoogleAdsFailure|!Error)} error
648
+ * @param {!Array<string>} lines The original input data.
649
+ * @param {number} fieldPathIndex The index of 'FieldPathElement' in the array
650
+ * 'field_path_elements'. This is used to get the original line related to
651
+ * this GoogleAdsError.
652
+ */
653
+ updateBatchResultWithError(batchResult, error, lines, fieldPathIndex) {
654
+ batchResult.result = false;
655
+ if (error.metadata) {
656
+ const failures = this.getGoogleAdsFailures_(error.metadata);
657
+ if (failures.length > 0) {
658
+ debugGoogleAdsFailure(failures, lines)
659
+ this.extraFailedLines_(batchResult, failures, lines, fieldPathIndex);
660
+ return;
661
+ }
662
+ this.logger.warn(
663
+ 'Got an error with metadata but no GoogleAdsFailure in it', error);
664
+ }
665
+ this.logger.warn('Got an error without metadata', error);
666
+ batchResult.errors = [error.message || error.toString()];
667
+ }
668
+
669
+ /**
670
+ * Returns the GoogleAdsFailure from a wrapped gRPC error's metadata.
671
+ * @param {Metadata} metadata
672
+ * @return {!Array<!GoogleAdsFailure>}
673
+ */
674
+ getGoogleAdsFailures_(metadata) {
675
+ const errors = metadata.getMap();
676
+ const googleAdsFailures = Object.keys(errors)
677
+ .filter((key) => key.indexOf('googleadsfailure') > -1)
678
+ .map((key) => GoogleAdsFailure.decode(errors[key]));
679
+ return googleAdsFailures;
680
+ }
681
+
682
+
683
+ /**
684
+ * Extras failed lines based on the Array of GoogleAdsFailure.
685
+ *
686
+ * Different errors have different 'fieldPathIndex' which is the index of
687
+ * failed lines in original input data (an array of a string).
688
+ *
689
+ * For conversions, the ErrorLocation is like:
690
+ * ErrorLocation {
691
+ * fieldPathElements: [
692
+ * FieldPathElement { fieldName: 'operations', index: 0 },
693
+ * FieldPathElement { fieldName: 'create' }
694
+ * ]
695
+ * }
696
+ *
697
+ * For customer match upload, the ErrorLocation is like:
698
+ * ErrorLocation {
699
+ * fieldPathElements: [
700
+ * FieldPathElement { fieldName: 'operations', index: 0 },
701
+ * FieldPathElement { fieldName: 'create' },
702
+ * FieldPathElement { fieldName: 'userIdentifiers', index: 0 },
703
+ * FieldPathElement { fieldName: 'hashedEmail' }
704
+ * ]
705
+ * }
706
+ *
707
+ * The fieldPathElements can help to locate the details of the data cause the
708
+ * error.
709
+ * Currently, just leverage the first `FieldPathElement` as it is enough to
710
+ * locate the origin line in the input data.
711
+ * Note, this can be used to extract the precise information of the root cause
712
+ * of the error.
713
+ *
714
+ * With this we can get errors and failed lines. The function will set
715
+ * following for the given BatchResult object:
716
+ * result - false
717
+ * errors - de-duplicated error reasons
718
+ * failedLines - failed lines, an array of string. Without the reason of
719
+ * failure.
720
+ * groupedFailed - a hashmap of failed the lines. The key is the reason, the
721
+ * value is the array of failed lines due to this reason.
722
+ * @param {!BatchResult} batchResult
723
+ * @param {!Array<!GoogleAdsFailure>} failures
724
+ * @param {!Array<string>} lines The original input data.
725
+ * @param {number} fieldPathIndex The index of 'FieldPathElement' in the array
726
+ * 'fieldPathElements'. This is used to get the original line related to
727
+ * this GoogleAdsError.
728
+ * @private
729
+ */
730
+ extraFailedLines_(batchResult, failures, lines, fieldPathIndex) {
731
+ batchResult.result = false;
732
+ batchResult.failedLines = [];
733
+ batchResult.groupedFailed = {};
734
+ const errors = new Set();
735
+ failures.forEach((failure) => {
736
+ this.logger.error(`API requestId[${failure.requestId}]`,
737
+ failure.errors.map(({ message }) => message));
738
+ failure.errors.forEach(({ message, location }) => {
739
+ errors.add(message);
740
+ if (location && location.fieldPathElements[fieldPathIndex]) {
741
+ const { index } = location.fieldPathElements[fieldPathIndex];
742
+ if (index === null) {
743
+ this.logger.warn(`Unknown field path index: ${fieldPathIndex}`,
744
+ location.fieldPathElements);
745
+ } else {
746
+ const groupedFailed = batchResult.groupedFailed[message] || [];
747
+ const failedLine = lines[index];
748
+ batchResult.failedLines.push(failedLine);
749
+ groupedFailed.push(failedLine);
750
+ if (groupedFailed.length === 1) {
751
+ batchResult.groupedFailed[message] = groupedFailed;
752
+ }
753
+ }
754
+ }
755
+ });
756
+ });
757
+ batchResult.errors = Array.from(errors);
758
+ }
759
+
760
+ /**
761
+ * Uploads call conversions to google ads account.
762
+ * In DEBUG mode, this function will only validate the conversions.
763
+ * @see https://developers.google.com/google-ads/api/reference/rpc/v15/CallConversion
764
+ * @param {!Array<string>} lines Data for single request. It should be
765
+ * guaranteed that it doesn't exceed quota limitation.
766
+ * @param {!ConversionConfig} config Default conversion parameters.
767
+ * @return {!Promise<!UploadCallConversionsResponse>}
768
+ */
769
+ async uploadCallConversions(lines, config) {
770
+ const { customerId, loginCustomerId } = config;
771
+ this.logger.debug('Upload call conversions for:', config);
772
+ const conversions =
773
+ buildConversionJsonList(lines, config, CONVERSION_FIELDS.CALL);
774
+ const callConversions =
775
+ conversions.map((conversion) => new CallConversion(conversion));
776
+ const client = await this.getConversionUploadServiceClient_();
777
+ const request = new UploadCallConversionsRequest({
778
+ conversions: callConversions,
779
+ customerId,
780
+ validateOnly: this.debugMode, // when true makes no changes
781
+ partialFailure: true, // Will still create the non-failed entities
782
+ });
783
+ const callOptions = this.getCallOptions_(loginCustomerId);
784
+ const [response] = await client.uploadCallConversions(request, callOptions);
785
+ return response;
786
+ }
787
+
788
+ /**
789
+ * Uploads click conversions to google ads account.
790
+ * In DEBUG mode, this function will only validate the conversions.
791
+ * @param {!Array<string>} lines Data for single request. It should be
792
+ * guaranteed that it doesn't exceed quota limitation.
793
+ * @param {!ConversionConfig} config Default conversion parameters.
794
+ * @return {!Promise<!UploadClickConversionsResponse>}
795
+ */
796
+ async uploadClickConversions(lines, config) {
797
+ const { customerId, loginCustomerId } = config;
798
+ this.logger.debug('Upload click conversions for:', config);
799
+ const conversions = buildConversionJsonList(lines, config,
800
+ CONVERSION_FIELDS.CLICK, IDENTIFIERS.CLICK_CONVERSION, MAX_IDENTIFIERS.CONVERSION);
801
+ const clickConversions =
802
+ conversions.map((conversion) => new ClickConversion(conversion));
803
+ const client = await this.getConversionUploadServiceClient_();
804
+ const request = new UploadClickConversionsRequest({
805
+ conversions: clickConversions,
806
+ customerId,
807
+ validateOnly: this.debugMode, // when true makes no changes
808
+ partialFailure: true, // Will still create the non-failed entities
809
+ });
810
+ const callOptions = this.getCallOptions_(loginCustomerId);
811
+ const [response] = await client.uploadClickConversions(request, callOptions);
812
+ return response;
813
+ }
814
+
815
+ /**
816
+ * Uploads conversion adjustments to google ads account.
817
+ * In DEBUG mode, this function will only validate the conversion adjustments.
818
+ * @param {!Array<string>} lines Data for single request. It should be
819
+ * guaranteed that it doesn't exceed quota limitation.
820
+ * @param {!ConversionConfig} config Default conversion parameters.
821
+ * @return {!Promise<!UploadConversionAdjustmentsResponse>}
822
+ */
823
+ async uploadConversionAdjustments(lines, config) {
824
+ const { customerId, loginCustomerId } = config;
825
+ this.logger.debug('Upload conversion adjustments for:', config);
826
+ const conversions = buildConversionJsonList(
827
+ lines, config, IDENTIFIERS.CONVERSION_ADJUSTMENT, MAX_IDENTIFIERS.CONVERSION);
828
+ const conversionAdjustments =
829
+ conversions.map((conversion) => new ConversionAdjustment(conversion));
830
+ const client = await this.getConversionAdjustmentUploadServiceClient_();
831
+ const request = new UploadConversionAdjustmentsRequest({
832
+ conversionAdjustments,
833
+ customerId,
834
+ validateOnly: this.debugMode, // when true makes no changes
835
+ partialFailure: true, // Will still create the non-failed entities
836
+ });
837
+ const callOptions = this.getCallOptions_(loginCustomerId);
838
+ const [response] =
839
+ await client.uploadConversionAdjustments(request, callOptions);
840
+ return response;
841
+ }
842
+
843
+ /**
844
+ * Returns the id of Conversion Custom Variable with the given tag.
845
+ * @param {string} tag Custom Variable tag.
846
+ * @param {string} customerId
847
+ * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
848
+ * @return {Promise<string|undefined>} Returns undefined if can't find tag.
849
+ */
850
+ async getConversionCustomVariableId(tag, customerId, loginCustomerId) {
851
+ const query = `
852
+ SELECT conversion_custom_variable.id,
853
+ conversion_custom_variable.tag
854
+ FROM conversion_custom_variable
855
+ WHERE conversion_custom_variable.tag = "${tag}" LIMIT 1
856
+ `;
857
+ const customVariables =
858
+ await this.getReport(customerId, loginCustomerId, query);
859
+ if (customVariables.length > 0) {
860
+ return customVariables[0].conversionCustomVariable.id;
861
+ }
862
+ }
863
+
864
+ /**
865
+ * Gets the user listId of a given list name and upload key type. It
866
+ * only looks for a CRM_BASED and OPEN list.
867
+ * @param {!CustomerMatchConfig} customerMatchConfig
868
+ * @return {number|undefined} User list_id if it exists.
869
+ */
870
+ async getCustomerMatchUserListId(customerMatchConfig) {
871
+ const config = this.getCamelConfig_(customerMatchConfig);
872
+ const { customerId, loginCustomerId, listName, uploadKeyType, } = config;
873
+ const query = `
874
+ SELECT user_list.id, user_list.resource_name
875
+ FROM user_list
876
+ WHERE user_list.name = '${listName}'
877
+ AND customer.id = ${this.getCleanCid_(customerId)}
878
+ AND user_list.type = CRM_BASED
879
+ AND user_list.membership_status = OPEN
880
+ AND user_list.crm_based_user_list.upload_key_type = ${uploadKeyType}
881
+ `;
882
+ const userlists = await this.getReport(customerId, loginCustomerId, query);
883
+ return userlists.length === 0 ? undefined : userlists[0].userList.id;
884
+ }
885
+
886
+ /**
887
+ * Creates the user list based on a given customerMatchConfig and returns the
888
+ * Id. The user list would be a CRM_BASED type.
889
+ * Trying to create a list with an used name will fail.
890
+ * The Google Ads service behind this function (UserListService) supports
891
+ * `partial_failure`. Here, we only create one userlist at one time, so
892
+ * `partial_failure` is disabled to simplified the error handleing process.
893
+ * @see getUploadConversionFnBase_ for more details of error handling.
894
+ * @param {!CustomerMatchConfig} customerMatchConfig
895
+ * @return {number} The created user list id. Note this is not the resource
896
+ * name.
897
+ */
898
+ async createCustomerMatchUserList(customerMatchConfig) {
899
+ const config = this.getCamelConfig_(customerMatchConfig);
900
+ const { customerId, loginCustomerId, listName, uploadKeyType, } = config;
901
+ const userList = new UserList({
902
+ name: listName,
903
+ type: UserListType.CRM_BASED,
904
+ crmBasedUserList: { uploadKeyType },
905
+ });
906
+ const request = new MutateUserListsRequest({
907
+ customerId: this.getCleanCid_(customerId),
908
+ operations: [{ create: userList }],
909
+ validateOnly: this.debugMode, // when true makes no changes
910
+ partialFailure: false, // Simplify error handling in creating userlist
911
+ });
912
+ const client = await this.getUserListServiceClient_();
913
+ const options = this.getCallOptions_(loginCustomerId);
914
+ try {
915
+ /**
916
+ * @type {!MutateUserListsResponse}
917
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/MutateUserListsResponse
918
+ */
919
+ const [response] = await client.mutateUserLists(request, options);
920
+ const { results } = response; // No `partialFailureError` here.
921
+ this.logger.debug(`Created crm userlist from`, customerMatchConfig);
922
+ if (!results[0]) {
923
+ if (this.debugMode) {
924
+ throw new Error('No UserList was created in DEBUG mode.');
925
+ } else {
926
+ throw new Error('No UserList was created.');
927
+ }
928
+ }
929
+ const { resourceName } = results[0];
930
+ const splitted = resourceName.split('/');
931
+ return splitted[splitted.length - 1];
932
+ } catch (error) {
933
+ // Get the details from a wrapped GoogleAdsFailure
934
+ if (error.metadata) {
935
+ const failures = this.getGoogleAdsFailures_(error.metadata);
936
+ if (failures.length > 0) {
937
+ if (this.logger.isDebugEnabled())
938
+ debugGoogleAdsFailure(failures, request);
939
+ const message = failures.map((failure) => {
940
+ return failure.errors.map(({ message }) => message).join(' ');
941
+ }).join(';');
942
+ throw new Error(message);
943
+ }
944
+ this.logger.warn(
945
+ 'Got an error with metadata but no GoogleAdsFailure in it', error);
946
+ }
947
+ throw error;
948
+ }
949
+ }
950
+
951
+ /**
952
+ * Returns the function to send out a request to Google Ads API with
953
+ * user ids for Customer Match upload.
954
+ * This API does NOT support the field 'userIdentifierSource'.
955
+ * @param {!CustomerMatchConfig} customerMatchConfig
956
+ * @return {!SendSingleBatch} Function which can send a batch of hits to
957
+ * Google Ads API.
958
+ */
959
+ getUploadCustomerMatchFn(customerMatchConfig) {
960
+ /**
961
+ * Sends a batch of hits to Google Ads API.
962
+ * @param {!Array<string>} lines Data for single request. It should be
963
+ * guaranteed that it doesn't exceed quota limitation.
964
+ * @param {string} batchId The tag for log.
965
+ * @return {!Promise<BatchResult>}
966
+ */
967
+ return async (lines, batchId) => {
968
+ /** @const {BatchResult} */ const batchResult = {
969
+ result: true,
970
+ numberOfLines: lines.length,
971
+ };
972
+ try {
973
+ const response =
974
+ await this.uploadUserDataToUserList(lines, customerMatchConfig);
975
+ this.logger.debug(`Customer Match upload batch[${batchId}]`, response);
976
+ return batchResult;
977
+ } catch (error) {
978
+ this.logger.error(
979
+ `Error in Customer Match upload batch[${batchId}]`, error);
980
+ this.updateBatchResultWithError(batchResult, error, lines, 0);
981
+ return batchResult;
982
+ }
983
+ }
984
+ }
985
+
986
+ /**
987
+ * Uploads a user data to a user list (aka customer match). The service
988
+ * doesn't support partial failure. So it would throw out a wrapped
989
+ * GoogleAdsFailure if error happens. The error will be handled by function
990
+ * `getUploadCustomerMatchFn`.
991
+ * @see getUploadCustomerMatchFn
992
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserDataService
993
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserDataOperation
994
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserData
995
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserIdentifier
996
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/CustomerMatchUserListMetadata
997
+ * Please note: The UserDataService has a limit of 10 UserDataOperations
998
+ * and 100 user IDs per request
999
+ * @see https://developers.google.com/google-ads/api/docs/migration/user-data-service#rate_limits
1000
+ * @param {!Array<string>} lines An array of JSON string for user Ids
1001
+ * @param {!CustomerMatchConfig} customerMatchConfig
1002
+ * @return {!Promise<UploadUserDataResponse>}
1003
+ */
1004
+ async uploadUserDataToUserList(lines, customerMatchConfig) {
1005
+ const config = this.getCamelConfig_(customerMatchConfig);
1006
+ const { customerId, loginCustomerId, listId, operation, } = config;
1007
+ const client = await this.getUserDataServiceClient_();
1008
+ const userDataList = buildUserDataList(
1009
+ lines, config, IDENTIFIERS.CUSTOMER_MATCH, MAX_IDENTIFIERS.USER_DATA);
1010
+ const operations = userDataList.map(
1011
+ (userData) => new UserDataOperation({ [operation]: new UserData(userData) })
1012
+ );
1013
+ const metadata = this.buildCustomerMatchUserListMetadata_(customerId, listId);
1014
+ const request = new UploadUserDataRequest({
1015
+ customerId: this.getCleanCid_(customerId),
1016
+ operations,
1017
+ customerMatchUserListMetadata: metadata,
1018
+ });
1019
+ const options = this.getCallOptions_(loginCustomerId);
1020
+ const [response] = await client.uploadUserData(request, options);
1021
+ return response;
1022
+ }
1023
+
1024
+ /**
1025
+ * Creates CustomerMatchUserListMetadata.
1026
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/CustomerMatchUserListMetadata
1027
+ * @param {string} customerId part of the ResourceName to be mutated
1028
+ * @param {string} userListId part of the ResourceName to be mutated
1029
+ * @return {!CustomerMatchUserListMetadata}
1030
+ * @private
1031
+ */
1032
+ buildCustomerMatchUserListMetadata_(customerId, userListId) {
1033
+ const resourceName = `customers/${customerId}/userLists/${userListId}`;
1034
+ return new CustomerMatchUserListMetadata({
1035
+ userList: resourceName,
1036
+ });
1037
+ }
1038
+
1039
+ /**
1040
+ * Get OfflineUserDataJob status.
1041
+ * @param {OfflineUserDataJobConfig} offlineUserDataJobConfig
1042
+ * @param {string} resourceName
1043
+ * @return {string} Job status @see OfflineUserDataJobStatus
1044
+ */
1045
+ async getOfflineUserDataJob(offlineUserDataJobConfig, resourceName) {
1046
+ const config = this.getCamelConfig_(offlineUserDataJobConfig);
1047
+ const { customerId, loginCustomerId, } = config;
1048
+ const query = `
1049
+ SELECT offline_user_data_job.id,
1050
+ offline_user_data_job.status,
1051
+ offline_user_data_job.type,
1052
+ offline_user_data_job.customer_match_user_list_metadata.user_list,
1053
+ offline_user_data_job.failure_reason
1054
+ FROM offline_user_data_job
1055
+ WHERE offline_user_data_job.resource_name = '${resourceName}'
1056
+ `;
1057
+ const jobs = await this.getReport(customerId, loginCustomerId, query);
1058
+ if (jobs.length === 0) {
1059
+ throw new Error(`Can't find the OfflineUserDataJob: ${resourceName}`);
1060
+ }
1061
+ const { failureReason, status } = jobs[0].offlineUserDataJob;
1062
+ if (OfflineUserDataJobFailureReason[failureReason] > 0) {
1063
+ this.logger.warn(
1064
+ `OfflineUserDataJob [${resourceName}] failed: `, failureReason);
1065
+ }
1066
+ return status;
1067
+ }
1068
+
1069
+ /**
1070
+ * Creates a OfflineUserDataJob and returns resource name.
1071
+ * @param {OfflineUserDataJobConfig} offlineUserDataJobConfig
1072
+ * @return {string} The resouce name of the creaed job.
1073
+ */
1074
+ async createOfflineUserDataJob(offlineUserDataJobConfig) {
1075
+ const config = this.getCamelConfig_(offlineUserDataJobConfig);
1076
+ const { customerId, loginCustomerId, listId, type, } = config;
1077
+ this.logger.debug('Creating OfflineUserDataJob from:',
1078
+ offlineUserDataJobConfig);
1079
+ const jobData = { type };
1080
+ // https://developers.google.com/google-ads/api/rest/reference/rest/latest/OfflineUserDataJobs?hl=en#CustomerMatchUserListMetadata
1081
+ if (type.startsWith('CUSTOMER_MATCH')) {
1082
+ const metadata = this.buildCustomerMatchUserListMetadata_(customerId,
1083
+ listId);
1084
+ jobData.customerMatchUserListMetadata = metadata;
1085
+ // https://developers.google.com/google-ads/api/rest/reference/rest/latest/OfflineUserDataJob?hl=en#StoreSalesMetadata
1086
+ } else if (type.startsWith('STORE_SALES')) {
1087
+ // Support previous property 'StoreSalesMetadata' for compatibility.
1088
+ if (config.storeSalesMetadata || config.StoreSalesMetadata) {
1089
+ jobData.storeSalesMetadata = config.storeSalesMetadata
1090
+ || config.StoreSalesMetadata;
1091
+ }
1092
+ } else {
1093
+ throw new Error(`UNSUPPORTED OfflineUserDataJobType: ${type}.`);
1094
+ }
1095
+ const job = new OfflineUserDataJob(jobData);
1096
+ const request = new CreateOfflineUserDataJobRequest({
1097
+ customerId: this.getCleanCid_(customerId),
1098
+ job,
1099
+ validateOnly: this.debugMode, // when true makes no changes
1100
+ enableMatchRateRangePreview: true,
1101
+ });
1102
+ const client = await this.getOfflineUserDataJobServiceClient_();
1103
+ const options = this.getCallOptions_(loginCustomerId);
1104
+ try {
1105
+ /**
1106
+ * @type {!CreateOfflineUserDataJobResponse}
1107
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/CreateOfflineUserDataJobResponse
1108
+ */
1109
+ const [response] = await client.createOfflineUserDataJob(request, options);
1110
+ const { resourceName } = response;
1111
+ if (!resourceName) {
1112
+ if (this.debugMode) {
1113
+ throw new Error('No offlineUserDataJob was created in DEBUG mode.');
1114
+ } else {
1115
+ throw new Error('No offlineUserDataJob was created.');
1116
+ }
1117
+ }
1118
+ this.logger.info('Created OfflineUserDataJob:', resourceName);
1119
+ return resourceName;
1120
+ } catch (error) {
1121
+ if (error.metadata) {
1122
+ const failures = this.getGoogleAdsFailures_(error.metadata);
1123
+ if (failures.length > 0) {
1124
+ if (this.logger.isDebugEnabled())
1125
+ debugGoogleAdsFailure(failures, request);
1126
+ const message = failures.map((failure) => {
1127
+ return failure.errors.map(({ message }) => message).join(' ');
1128
+ }).join(';');
1129
+ throw new Error(message);
1130
+ }
1131
+ this.logger.warn(
1132
+ 'Got an error with metadata but no GoogleAdsFailure in it', error);
1133
+ }
1134
+ throw error;
1135
+ }
1136
+ }
1137
+
1138
+ /**
1139
+ * Returns the function to send out a request to Google Ads API with
1140
+ * user data as operations in OfflineUserDataJob.
1141
+ * @param {!OfflineUserDataJobConfig} config
1142
+ * @param {string} jobResourceName
1143
+ * @return {!SendSingleBatch} Function which can send a batch of hits to
1144
+ * Google Ads API.
1145
+ */
1146
+ getAddOperationsToOfflineUserDataJobFn(config, jobResourceName) {
1147
+ /**
1148
+ * Sends a batch of hits to Google Ads API.
1149
+ * @param {!Array<string>} lines Data for single request. It should be
1150
+ * guaranteed that it doesn't exceed quota limitation.
1151
+ * @param {string} batchId The tag for log.
1152
+ * @return {!Promise<BatchResult>}
1153
+ */
1154
+ return async (lines, batchId) => {
1155
+ /** @const {BatchResult} */ const batchResult = {
1156
+ result: true,
1157
+ numberOfLines: lines.length,
1158
+ };
1159
+ try {
1160
+ const response = await this.addOperationsToOfflineUserDataJob(
1161
+ lines, config, jobResourceName);
1162
+ this.logger.debug(`Add operation to job batch[${batchId}]`, response);
1163
+ const { partialFailureError: failed } = response;
1164
+ if (failed) {
1165
+ this.logger.info(`Job[${jobResourceName}] faile:`, failed.message);
1166
+ const failures = failed.details.map(
1167
+ ({ value }) => GoogleAdsFailure.decode(value));
1168
+ this.extraFailedLines_(batchResult, failures, lines, 0);
1169
+ }
1170
+ return batchResult;
1171
+ } catch (error) {
1172
+ this.logger.error(
1173
+ `Error in OfflineUserDataJob add operations batch[${batchId}]`, error);
1174
+ console.log(batchResult);
1175
+ this.updateBatchResultWithError(batchResult, error, lines, 0);
1176
+ return batchResult;
1177
+ }
1178
+ }
1179
+ }
1180
+
1181
+ /**
1182
+ * Adds user data in to the OfflineUserDataJob.
1183
+ * @param {!Array<string>} lines An array of JSON string for user Ids
1184
+ * @param {OfflineUserDataJobConfig} config Offline user data job config.
1185
+ * @param {string} jobResourceName
1186
+ * @return {!Promise<!AddOfflineUserDataJobOperationsResponse>}
1187
+ */
1188
+ async addOperationsToOfflineUserDataJob(lines, offlineUserDataJobConfig,
1189
+ jobResourceName) {
1190
+ const config = this.getCamelConfig_(offlineUserDataJobConfig);
1191
+ const { loginCustomerId, operation, type } = config;
1192
+ const client = await this.getOfflineUserDataJobServiceClient_();
1193
+ const identifierTypes = type.startsWith('CUSTOMER_MATCH')
1194
+ ? IDENTIFIERS.CUSTOMER_MATCH : IDENTIFIERS.STORE_SALES;
1195
+ const userDataList = buildUserDataList(
1196
+ lines, config, identifierTypes, MAX_IDENTIFIERS.USER_DATA);
1197
+ const operations = userDataList.map((userData) => {
1198
+ return new OfflineUserDataJobOperation(
1199
+ { [operation]: new UserData(userData) });
1200
+ });
1201
+ const request = new AddOfflineUserDataJobOperationsRequest({
1202
+ resourceName: jobResourceName,
1203
+ operations,
1204
+ validateOnly: this.debugMode,
1205
+ enablePartialFailure: true,
1206
+ enableWarnings: true,
1207
+ });
1208
+ const options = this.getCallOptions_(loginCustomerId);
1209
+ const [response] =
1210
+ await client.addOfflineUserDataJobOperations(request, options);
1211
+ return response;
1212
+ }
1213
+
1214
+ /**
1215
+ * Starts the OfflineUserDataJob.
1216
+ * @param {OfflineUserDataJobConfig} offlineUserDataJobConfig Offline user data job config.
1217
+ * @param {string} jobResourceName
1218
+ * @return {!Promise<!Operation>}
1219
+ */
1220
+ async runOfflineUserDataJob(offlineUserDataJobConfig, jobResourceName) {
1221
+ const config = this.getCamelConfig_(offlineUserDataJobConfig);
1222
+ const { loginCustomerId } = config;
1223
+ const client = await this.getOfflineUserDataJobServiceClient_();
1224
+ const request = new RunOfflineUserDataJobRequest({
1225
+ resourceName: jobResourceName,
1226
+ validateOnly: this.debugMode,
1227
+ });
1228
+ const options = this.getCallOptions_(loginCustomerId);
1229
+ try {
1230
+ const [response] = await client.runOfflineUserDataJob(request, options);
1231
+ this.logger.debug('runOfflineUserDataJob response: ', response);
1232
+ return response;
1233
+ } catch (error) {
1234
+ if (error.metadata) {
1235
+ const failures = this.getGoogleAdsFailures_(error.metadata);
1236
+ if (failures.length > 0) {
1237
+ if (this.logger.isDebugEnabled())
1238
+ debugGoogleAdsFailure(failures, request);
1239
+ const message = failures.map((failure) => {
1240
+ return failure.errors.map(({ message }) => message).join(' ');
1241
+ }).join(';');
1242
+ throw new Error(message);
1243
+ }
1244
+ this.logger.warn(
1245
+ 'Got an error with metadata but no GoogleAdsFailure in it', error);
1246
+ }
1247
+ throw error;
1248
+ }
1249
+ }
1250
+
1251
+ /**
1252
+ * Returns a integer format CID by removing dashes.
1253
+ * @param {string} cid
1254
+ * @return {string}
1255
+ * @private
1256
+ */
1257
+ getCleanCid_(cid) {
1258
+ return cid.toString().replace(/-/g, '');
1259
+ }
1260
+
1261
+ /**
1262
+ * Historically, we used a 3rd party library that adopted snake naming
1263
+ * convention as the protobuf files and API documents. However, the
1264
+ * automatically generated NodeJS library requires lowerCamel naming
1265
+ * convention. To reduce the impact to existing users, the function can
1266
+ * convert a 'snake' object to a 'lowerCamel' object.
1267
+ * @param {Object} config
1268
+ * @return {object}
1269
+ */
1270
+ getCamelConfig_(config) {
1271
+ return changeObjectNamingFromSnakeToLowerCamel(config);
1272
+ }
1273
+
1274
+ /**
1275
+ * Returns a HTTP header object contains the authentication information for
1276
+ * Google Ads API, include: `developer-token` and `ogin-customer-id`.
1277
+ * @param {string} loginCustomerId
1278
+ * @return {object} The HTTP header object.
1279
+ * @private
1280
+ */
1281
+ getGoogleAdsHeaders_(loginCustomerId) {
1282
+ return {
1283
+ "developer-token": this.developerToken,
1284
+ "login-customer-id": loginCustomerId,
1285
+ };
1286
+ }
1287
+
1288
+ /**
1289
+ * Returns an option object as gRPC call options. This is used to add Google
1290
+ * Ads API required authentication information to requests.
1291
+ * @param {string} loginCustomerId
1292
+ * @return {{otherArgs:object}}
1293
+ * @private
1294
+ */
1295
+ getCallOptions_(loginCustomerId) {
1296
+ return {
1297
+ otherArgs: {
1298
+ headers: this.getGoogleAdsHeaders_(this.getCleanCid_(loginCustomerId)),
1299
+ },
1300
+ };
1301
+ }
1302
+
1303
+ /**
1304
+ * Prepares the feach data service client instance.
1305
+ * @return {!GoogleAdsServiceClient}
1306
+ * @private
1307
+ */
1308
+ async getGoogleAdsServiceClient_() {
1309
+ if (this.googleAdsClient) return this.googleAdsClient;
1310
+ await this.authClient.prepareCredentials();
1311
+ this.googleAdsClient = new GoogleAdsServiceClient({
1312
+ authClient: this.authClient.getDefaultAuth(),
1313
+ });
1314
+ return this.googleAdsClient;
1315
+ }
1316
+
1317
+ /**
1318
+ * Prepares the Google Ads field service client instance.
1319
+ * @return {!GoogleAdsFieldServiceClient}
1320
+ * @private
1321
+ */
1322
+ async getGoogleAdsFieldServiceClient_() {
1323
+ await this.authClient.prepareCredentials();
1324
+ return new GoogleAdsFieldServiceClient({
1325
+ authClient: this.authClient.getDefaultAuth(),
1326
+ });
1327
+ }
1328
+
1329
+ /**
1330
+ * Prepares the conversion upload service client instance.
1331
+ * @return {!ConversionUploadServiceClient}
1332
+ * @private
1333
+ */
1334
+ async getConversionUploadServiceClient_() {
1335
+ await this.authClient.prepareCredentials();
1336
+ return new ConversionUploadServiceClient({
1337
+ authClient: this.authClient.getDefaultAuth(),
1338
+ });
1339
+ }
1340
+
1341
+ /**
1342
+ * Prepares the conversion adjustment upload service client instance.
1343
+ * @return {!ConversionAdjustmentUploadServiceClient}
1344
+ * @private
1345
+ */
1346
+ async getConversionAdjustmentUploadServiceClient_() {
1347
+ await this.authClient.prepareCredentials();
1348
+ return new ConversionAdjustmentUploadServiceClient({
1349
+ authClient: this.authClient.getDefaultAuth(),
1350
+ });
1351
+ }
1352
+
1353
+ /**
1354
+ * Prepares the user list manage service client instance.
1355
+ * @return {!UserListServiceClient}
1356
+ * @private
1357
+ */
1358
+ async getUserListServiceClient_() {
1359
+ await this.authClient.prepareCredentials();
1360
+ return new UserListServiceClient({
1361
+ authClient: this.authClient.getDefaultAuth(),
1362
+ });
1363
+ }
1364
+
1365
+ /**
1366
+ * Prepares the user data upload service client instance.
1367
+ * @return {!UserDataServiceClient}
1368
+ * @private
1369
+ */
1370
+ async getUserDataServiceClient_() {
1371
+ await this.authClient.prepareCredentials();
1372
+ return new UserDataServiceClient({
1373
+ authClient: this.authClient.getDefaultAuth(),
1374
+ });
1375
+ }
1376
+
1377
+ /**
1378
+ * Prepares the offline user data job manage service client instance.
1379
+ * @return {!OfflineUserDataJobServiceClient}
1380
+ * @private
1381
+ */
1382
+ async getOfflineUserDataJobServiceClient_() {
1383
+ await this.authClient.prepareCredentials();
1384
+ return new OfflineUserDataJobServiceClient({
1385
+ authClient: this.authClient.getDefaultAuth(),
1386
+ });
1387
+ }
1388
+ }
1389
+
1390
+ /**
1391
+ * Returns an arrary of UserIdentifier object based the given JSON object.
1392
+ * @param {Object} record An object contains user indentifier information.
1393
+ * @param {!Array<string>} identifierTypes An list of user identifier types that
1394
+ * are supported by the target service.
1395
+ * @param {number} maximumNumOfIdentifiers The maximum number of user
1396
+ * identifiers in a conversion or a UserData.
1397
+ * @param {!UserIdentifierSource} defaultSource The value of `
1398
+ * UserIdentifierSource` in config. This is not supported by UserDataService
1399
+ * or 'CUSTOMER_MATCH' type OfflineUserDataJob.
1400
+ * @return {!Array<!UserData>}
1401
+ * @see UserData
1402
+ */
1403
+ const buildUserIdentifierList = (record, identifierTypes,
1404
+ maximumNumOfIdentifiers, defaultSource) => {
1405
+ const { userIdentifierSource = defaultSource } = record;
1406
+ const userIdentifiers = [];
1407
+ identifierTypes.forEach((identifierType) => {
1408
+ const identifierValue = record[identifierType];
1409
+ if (identifierValue) {
1410
+ if (Array.isArray(identifierValue)) {
1411
+ identifierValue.forEach((id) => {
1412
+ userIdentifiers.push(new UserIdentifier({
1413
+ [identifierType]: id,
1414
+ userIdentifierSource,
1415
+ }));
1416
+ });
1417
+ } else {
1418
+ userIdentifiers.push(new UserIdentifier({
1419
+ [identifierType]: identifierValue,
1420
+ userIdentifierSource,
1421
+ }));
1422
+ }
1423
+ }
1424
+ });
1425
+ if (userIdentifiers.length <= maximumNumOfIdentifiers) {
1426
+ return userIdentifiers;
1427
+ } else {
1428
+ this.logger.warn(
1429
+ `Too many user identifiers, will only send ${maximumNumOfIdentifiers}:`,
1430
+ JSON.stringify(record));
1431
+ return userIdentifiers.slice(0, MAX_IDENTIFIERS_PER_USER);
1432
+ }
1433
+ }
1434
+
1435
+ /**
1436
+ * Returns an array of conversion JSON objects based on the given arran of JSON
1437
+ * strings. The result can be used to create objects of ClickConversion,
1438
+ * CallConversion or ConversionAdjustment.
1439
+ * @param {!Array<string>} lines An array of JSON strings of conversions.
1440
+ * @param {ConversionConfig} config Conversion configuration.
1441
+ * @param {!Array<string>} conversionFields An list of user identifier types that
1442
+ * are supported by the target service.
1443
+ * @param {!Array<string>} identifierTypes An list of user identifier types that
1444
+ * are supported by the target service.
1445
+ * @param {number} maximumNumOfIdentifiers The maximum number of user
1446
+ * identifiers in a conversion or a UserData.
1447
+ * @return {!Array<object>} An array of objects to create ClickConversion,
1448
+ * CallConversion or ConversionAdjustment
1449
+ */
1450
+ const buildConversionJsonList = (lines, config, conversionFields,
1451
+ identifierTypes = [], maximumNumOfIdentifiers = 0) => {
1452
+ const { customerId, customVariables, userIdentifierSource } = config;
1453
+ const configProperties = lodash.pick(config, conversionFields);
1454
+ return lines.map((line) => JSON.parse(line))
1455
+ .map(changeObjectNamingFromSnakeToLowerCamel)
1456
+ //XXX: extra steps here to keep compatibility for snake convention
1457
+ .map((record) => {
1458
+ const conversion = lodash.merge({}, configProperties,
1459
+ lodash.pick(record, conversionFields));
1460
+ if (customVariables) {
1461
+ const variables = Object.keys(customVariables);
1462
+ conversion.customVariables = variables.map((variable) => {
1463
+ return new CustomVariable({
1464
+ conversionCustomVariable:
1465
+ `customers/${customerId}/conversionCustomVariables/${customVariables[variable]}`,
1466
+ value: record[variable],
1467
+ });
1468
+ });
1469
+ }
1470
+ if (identifierTypes.length > 0) {
1471
+ const userIdentifiers = buildUserIdentifierList(
1472
+ record,
1473
+ identifierTypes,
1474
+ maximumNumOfIdentifiers,
1475
+ userIdentifierSource
1476
+ );
1477
+ if (userIdentifiers.length > 0) {
1478
+ conversion.userIdentifiers = userIdentifiers;
1479
+ }
1480
+ }
1481
+ return conversion;
1482
+ })
1483
+ }
1484
+ /**
1485
+ * Returns an arrary of UserData object based on the given arran of JSON strings.
1486
+ * @param {!Array<string>} lines An array of JSON strings of UserData.
1487
+ * @param {{
1488
+ * additionalAttributes: (!UserIdentifierSource|undefined)
1489
+ * transactionAttribute: (!TransactionAttribute|undefined),
1490
+ * userAttribute: (!UserAttribute|undefined),
1491
+ * consent: (!Consent|undefined),
1492
+ * }} config
1493
+ * @see USERDATA_ADDITIONAL_ATTRIBUTES
1494
+ * @see UserIdentifierSource Not available in UserDataService or 'CUSTOMER_MATCH'
1495
+ * type OfflineUserDataJob.
1496
+ * @return {!Array<!UserData>}
1497
+ * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserData
1498
+ */
1499
+ const buildUserDataList = (lines, config, identifierTypes,
1500
+ maximumNumOfIdentifiers) => {
1501
+ const configAttributes = lodash.pick(config, USERDATA_ADDITIONAL_ATTRIBUTES);
1502
+ const { userIdentifierSource } = config;
1503
+ return lines.map((line) => JSON.parse(line))
1504
+ //XXX: extra steps here to keep compatibility for snake conversion
1505
+ .map(changeObjectNamingFromSnakeToLowerCamel)
1506
+ .map((record) => {
1507
+ const userData = lodash.merge({}, configAttributes,
1508
+ lodash.pick(record, USERDATA_ADDITIONAL_ATTRIBUTES));
1509
+ userData.userIdentifiers = buildUserIdentifierList(
1510
+ record,
1511
+ identifierTypes,
1512
+ maximumNumOfIdentifiers,
1513
+ userIdentifierSource
1514
+ );
1515
+ return new UserData(userData);
1516
+ });
1517
+ }
1518
+
1519
+ /**
1520
+ * Prints the information of GoogleAdsFailure from a request.
1521
+ * @param {!Array<!GoogleAdsFailure>} failures
1522
+ * @param {(object|undefined)} request
1523
+ */
1524
+ const debugGoogleAdsFailure = (failures, request) => {
1525
+ failures.forEach(({ requestId, errors }) => {
1526
+ console.log('GoogleAdsFailure request id:', requestId);
1527
+ errors.forEach(({ message, location }, index) => {
1528
+ console.log(` GoogleAdsError[${index}]:`, message);
1529
+ console.log(` location:`, location);
1530
+ });
1531
+ });
1532
+ if (request) {
1533
+ console.log('Original request:',
1534
+ request.toJSON ? request.toJSON() : request);
1535
+ }
1536
+ }
1537
+
1538
+ module.exports = {
1539
+ ConversionConfig,
1540
+ CustomerMatchRecord,
1541
+ CustomerMatchConfig,
1542
+ OfflineUserDataJobConfig,
1543
+ GoogleAdsApi,
1544
+ buildUserIdentifierList,
1545
+ buildConversionJsonList,
1546
+ buildUserDataList,
1547
+ CONVERSION_FIELDS,
1548
+ IDENTIFIERS,
1549
+ MAX_IDENTIFIERS,
1550
+ };