@jaimeflneto/n8n-nodes-google-ads-conversion
Version:
n8n node for tracking conversions in Google Ads with support for batch processing, enhanced conversions, and comprehensive privacy compliance
1,096 lines (1,095 loc) • 113 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.GoogleAdsConversion = void 0;
const n8n_workflow_1 = require("n8n-workflow");
const crypto_1 = require("crypto");
// Custom error classes for better error categorization
class GoogleAdsAuthenticationError extends n8n_workflow_1.NodeOperationError {
constructor(node, message) {
super(node, `Authentication Error: ${message}`);
this.name = 'GoogleAdsAuthenticationError';
}
}
class GoogleAdsValidationError extends n8n_workflow_1.NodeOperationError {
constructor(node, message, field) {
const fieldInfo = field ? ` (Field: ${field})` : '';
super(node, `Validation Error: ${message}${fieldInfo}`);
this.name = 'GoogleAdsValidationError';
}
}
class GoogleAdsApiError extends n8n_workflow_1.NodeOperationError {
constructor(node, message, httpCode, apiErrorCode) {
super(node, `Google Ads API Error: ${message}`);
this.name = 'GoogleAdsApiError';
this.httpCode = httpCode;
this.apiErrorCode = apiErrorCode;
}
}
class GoogleAdsRateLimitError extends n8n_workflow_1.NodeOperationError {
constructor(node, message, retryAfter) {
super(node, `Rate Limit Error: ${message}`);
this.name = 'GoogleAdsRateLimitError';
this.retryAfter = retryAfter;
}
}
class GoogleAdsConversion {
constructor() {
this.description = {
displayName: 'Google Ads Conversion',
name: 'googleAdsConversion',
icon: 'file:googleAds.svg',
group: ['output'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["conversionAction"]}}',
description: 'Send conversion events to Google Ads for campaign optimization',
defaults: {
name: 'Google Ads Conversion',
},
// @ts-ignore - Compatibility with different n8n versions
inputs: ['main'],
// @ts-ignore - Compatibility with different n8n versions
outputs: ['main'],
credentials: [
{
name: 'googleAdsOAuth2',
required: true,
},
],
requestDefaults: {
baseURL: 'https://googleads.googleapis.com/v17',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
properties: [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Upload Click Conversion',
value: 'uploadClickConversion',
description: 'Upload a click conversion to Google Ads',
action: 'Upload a click conversion',
},
],
default: 'uploadClickConversion',
},
// Account Type Detection
{
displayName: 'Account Type',
name: 'accountType',
type: 'options',
options: [
{
name: 'Regular Google Ads Account',
value: 'regular',
description: 'Direct Google Ads account (not a manager account)',
},
{
name: 'Manager Account (MCC)',
value: 'manager',
description: 'Manager account that manages multiple client accounts',
},
],
default: 'regular',
description: 'Select your account type',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
{
displayName: 'Managed Account',
name: 'managedAccount',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
description: 'Select the managed account to upload conversions to',
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
placeholder: 'Select a managed account...',
typeOptions: {
searchListMethod: 'getManagedAccounts',
searchable: true,
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: '^[0-9]+$',
errorMessage: 'Customer ID must contain only numbers',
},
},
],
placeholder: '1234567890',
},
],
displayOptions: {
show: {
operation: ['uploadClickConversion'],
accountType: ['manager'],
},
},
},
// Conversion Data Section
{
displayName: 'Conversion Data',
name: 'conversionDataSection',
type: 'notice',
default: '',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
{
displayName: 'Conversion Action ID',
name: 'conversionAction',
type: 'string',
required: true,
default: '',
description: 'The conversion action resource name or ID from Google Ads',
hint: 'Found in Google Ads under Tools & Settings > Conversions',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
{
displayName: 'Conversion Date Time',
name: 'conversionDateTime',
type: 'string',
required: true,
default: '={{$now}}',
description: 'The date and time of the conversion. Accepts DateTime objects (like $now) or strings in YYYY-MM-DD HH:MM:SS+TZ format',
hint: 'Example: 2024-01-15 14:30:00+00:00 or use {{$now}} for current time',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
{
displayName: 'Conversion Value',
name: 'conversionValue',
type: 'number',
default: 0,
description: 'The value of the conversion (optional)',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
{
displayName: 'Currency Code',
name: 'currencyCode',
type: 'string',
default: 'USD',
description: 'Three-letter ISO 4217 currency code',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
{
displayName: 'Order ID',
name: 'orderId',
type: 'string',
default: '',
description: 'Unique transaction/order identifier (optional but recommended)',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
// User Identification Section
{
displayName: 'User Identification',
name: 'userIdentificationSection',
type: 'notice',
default: '',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
{
displayName: 'Identification Method',
name: 'identificationMethod',
type: 'options',
options: [
{
name: 'GCLID (Google Click ID)',
value: 'gclid',
description: 'Use Google Click ID for identification',
},
{
name: 'GBRAID (iOS App Install)',
value: 'gbraid',
description: 'Use GBRAID for iOS app install conversions',
},
{
name: 'WBRAID (iOS Web-to-App)',
value: 'wbraid',
description: 'Use WBRAID for iOS web-to-app conversions',
},
{
name: 'Enhanced Conversions',
value: 'enhanced',
description: 'Use hashed user data for enhanced conversions',
},
],
default: 'gclid',
description: 'Select the method to identify the user who converted',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
{
displayName: 'GCLID',
name: 'gclid',
type: 'string',
required: true,
default: '',
description: 'The Google Click ID from the ad click',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
identificationMethod: ['gclid'],
},
},
},
{
displayName: 'GBRAID',
name: 'gbraid',
type: 'string',
required: true,
default: '',
description: 'The GBRAID for iOS app install conversions',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
identificationMethod: ['gbraid'],
},
},
},
{
displayName: 'WBRAID',
name: 'wbraid',
type: 'string',
required: true,
default: '',
description: 'The WBRAID for iOS web-to-app conversions',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
identificationMethod: ['wbraid'],
},
},
},
// Enhanced Conversions Section
{
displayName: 'Enhanced Conversion Data',
name: 'enhancedConversionSection',
type: 'notice',
default: '',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
identificationMethod: ['enhanced'],
},
},
},
{
displayName: 'Email Address',
name: 'email',
type: 'string',
default: '',
description: 'User email address (will be automatically hashed)',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
identificationMethod: ['enhanced'],
},
},
},
{
displayName: 'Phone Number',
name: 'phoneNumber',
type: 'string',
default: '',
description: 'User phone number in E.164 format (will be automatically hashed)',
hint: 'Example: +1234567890',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
identificationMethod: ['enhanced'],
},
},
},
{
displayName: 'First Name',
name: 'firstName',
type: 'string',
default: '',
description: 'User first name (will be automatically hashed)',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
identificationMethod: ['enhanced'],
},
},
},
{
displayName: 'Last Name',
name: 'lastName',
type: 'string',
default: '',
description: 'User last name (will be automatically hashed)',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
identificationMethod: ['enhanced'],
},
},
},
{
displayName: 'Street Address',
name: 'streetAddress',
type: 'string',
default: '',
description: 'User street address (will be automatically hashed)',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
identificationMethod: ['enhanced'],
},
},
},
{
displayName: 'City',
name: 'city',
type: 'string',
default: '',
description: 'User city (will be automatically hashed)',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
identificationMethod: ['enhanced'],
},
},
},
{
displayName: 'State/Region',
name: 'state',
type: 'string',
default: '',
description: 'User state or region (will be automatically hashed)',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
identificationMethod: ['enhanced'],
},
},
},
{
displayName: 'Postal Code',
name: 'postalCode',
type: 'string',
default: '',
description: 'User postal/ZIP code (will be automatically hashed)',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
identificationMethod: ['enhanced'],
},
},
},
{
displayName: 'Country Code',
name: 'countryCode',
type: 'string',
default: '',
description: 'Two-letter ISO 3166-1 alpha-2 country code (will be automatically hashed)',
hint: 'Example: US, GB, DE',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
identificationMethod: ['enhanced'],
},
},
},
// Privacy Compliance Section
{
displayName: 'Privacy Compliance (EEA)',
name: 'privacySection',
type: 'notice',
default: '',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
{
displayName: 'Ad User Data Consent',
name: 'adUserDataConsent',
type: 'options',
options: [
{
name: 'Granted',
value: 'GRANTED',
description: 'User has consented to ad user data usage',
},
{
name: 'Denied',
value: 'DENIED',
description: 'User has denied consent for ad user data usage',
},
{
name: 'Unknown',
value: 'UNKNOWN',
description: 'Consent status is unknown',
},
],
default: 'UNKNOWN',
description: 'User consent for ad user data usage (required for EEA)',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
{
displayName: 'Ad Personalization Consent',
name: 'adPersonalizationConsent',
type: 'options',
options: [
{
name: 'Granted',
value: 'GRANTED',
description: 'User has consented to ad personalization',
},
{
name: 'Denied',
value: 'DENIED',
description: 'User has denied consent for ad personalization',
},
{
name: 'Unknown',
value: 'UNKNOWN',
description: 'Consent status is unknown',
},
],
default: 'UNKNOWN',
description: 'User consent for ad personalization (required for EEA)',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
// Debug Section
{
displayName: 'Advanced Options',
name: 'advancedSection',
type: 'notice',
default: '',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
{
displayName: 'Validate Only',
name: 'validateOnly',
type: 'boolean',
default: false,
description: 'Only validate the conversion data without actually uploading',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
{
displayName: 'Debug Mode',
name: 'debugMode',
type: 'boolean',
default: false,
description: 'Enable debug mode for detailed logging and response data',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
// Testing & Debug Section
{
displayName: 'Batch Processing',
name: 'batchProcessingSection',
type: 'notice',
default: '',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
{
displayName: 'Enable Batch Processing',
name: 'enableBatchProcessing',
type: 'boolean',
default: false,
description: 'Process multiple conversions in batches for better performance',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
{
displayName: 'Batch Size',
name: 'batchSize',
type: 'number',
default: 100,
description: 'Number of conversions to process in each batch (max 2000)',
typeOptions: {
minValue: 1,
maxValue: 2000,
},
displayOptions: {
show: {
operation: ['uploadClickConversion'],
enableBatchProcessing: [true],
},
},
},
{
displayName: 'Batch Processing Mode',
name: 'batchProcessingMode',
type: 'options',
options: [
{
name: 'Fail on First Error',
value: 'failFast',
description: 'Stop processing if any batch fails',
},
{
name: 'Continue on Errors',
value: 'continueOnError',
description: 'Continue processing even if some batches fail',
},
{
name: 'Partial Failure Mode',
value: 'partialFailure',
description: 'Use Google Ads partial failure policy (recommended)',
},
],
default: 'partialFailure',
description: 'How to handle errors during batch processing',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
enableBatchProcessing: [true],
},
},
},
{
displayName: 'Show Progress',
name: 'showProgress',
type: 'boolean',
default: true,
description: 'Log batch processing progress',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
enableBatchProcessing: [true],
},
},
},
// Testing & Debug Section (existing)
{
displayName: 'Testing & Debug',
name: 'testingSection',
type: 'notice',
default: '',
displayOptions: {
show: {
operation: ['uploadClickConversion'],
},
},
},
],
};
this.methods = {
listSearch: {
async getManagedAccounts() {
try {
console.log('getManagedAccounts: Starting request...');
// Get credentials and developer token from authentication
const credentials = await this.getCredentials('googleAdsOAuth2');
if (!credentials) {
console.error('getManagedAccounts: No credentials found');
throw new Error('No credentials provided for Google Ads OAuth2');
}
const managerCustomerId = credentials.customerId;
const developerToken = credentials.developerToken;
console.log('getManagedAccounts: Credentials check:', {
hasCustomerId: !!managerCustomerId,
hasDeveloperToken: !!developerToken,
customerIdLength: managerCustomerId?.length || 0,
});
if (!managerCustomerId) {
console.error('getManagedAccounts: Manager customer ID is missing');
throw new Error('Manager customer ID is required');
}
if (!developerToken) {
console.error('getManagedAccounts: Developer token is missing');
throw new Error('Developer token is required');
}
const sanitizedManagerId = managerCustomerId.replace(/\D/g, '');
if (!sanitizedManagerId) {
console.error('getManagedAccounts: Customer ID has no valid digits');
throw new Error('Customer ID must contain at least one digit');
}
const apiUrl = `/customers/${sanitizedManagerId}/googleAds:search`;
const baseUrl = 'https://googleads.googleapis.com/v17';
const requestHeaders = {
'developer-token': developerToken,
'login-customer-id': sanitizedManagerId,
Accept: 'application/json',
'Content-Type': 'application/json',
};
const requestBody = {
query: `
SELECT
customer_client.client_customer,
customer_client.descriptive_name,
customer_client.currency_code,
customer_client.time_zone,
customer_client.status
FROM customer_client
WHERE customer_client.status = 'ENABLED'
`,
pageSize: 1000,
};
console.log('Manager Account Request Debug:', {
rawCustomerId: managerCustomerId,
sanitizedCustomerId: sanitizedManagerId,
baseUrl,
endpoint: apiUrl,
fullUrl: `${baseUrl}${apiUrl}`,
headers: {
...requestHeaders,
'developer-token': developerToken ? '***HIDDEN***' : 'MISSING',
},
requestBody,
});
const response = await this.helpers.httpRequestWithAuthentication.call(this, 'googleAdsOAuth2', {
method: 'POST',
url: apiUrl,
body: requestBody,
headers: requestHeaders,
});
console.log('getManagedAccounts API Response:', response);
const results = [];
if (response.results && Array.isArray(response.results)) {
for (const result of response.results) {
const client = result.customerClient;
if (client && client.clientCustomer) {
const customerId = client.clientCustomer.replace('customers/', '');
const name = client.descriptiveName || `Account ${customerId}`;
const currency = client.currencyCode || '';
const timezone = client.timeZone || '';
results.push({
name: `${name} (${customerId})${currency ? ` - ${currency}` : ''}${timezone ? ` - ${timezone}` : ''}`,
value: customerId,
});
}
}
}
console.log('Managed accounts found:', results.length);
return {
results: results.sort((a, b) => a.name.localeCompare(b.name)),
};
}
catch (error) {
console.error('getManagedAccounts ERROR:', {
error: error.message,
httpCode: error.httpCode || error.status,
responseBody: error.response?.body || error.body,
requestConfig: error.config || error.request,
stack: error.stack,
fullError: error,
});
// Rethrow com informações mais específicas
const errorMessage = error.message || 'Unknown error occurred';
const httpCode = error.httpCode || error.status || 500;
throw new Error(`Failed to load managed accounts: ${errorMessage} (HTTP ${httpCode})`);
}
},
},
};
}
/**
* Sleep utility for retry delays
*/
async sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Retry configuration interface
*/
getRetryConfig() {
return {
maxRetries: 3,
baseDelayMs: 1000, // 1 second
maxDelayMs: 30000, // 30 seconds
retryableStatusCodes: [429, 500, 502, 503, 504],
retryableErrors: ['ECONNRESET', 'ENOTFOUND', 'ECONNREFUSED', 'ETIMEDOUT'],
};
}
/**
* Determine if an error should trigger a retry
*/
shouldRetry(error, retryAttempt, config) {
// Check if we've exceeded max retries
if (retryAttempt >= config.maxRetries) {
return false;
}
// Check for specific retryable HTTP status codes
const httpCode = error.httpCode || error.status || 0;
if (config.retryableStatusCodes.includes(httpCode)) {
return true;
}
// Check for specific network errors
const errorCode = error.code || error.errno || '';
if (config.retryableErrors.includes(errorCode)) {
return true;
}
// Check for specific error types that should be retried
if (error instanceof GoogleAdsRateLimitError) {
return true;
}
// Don't retry authentication or validation errors
if (error instanceof GoogleAdsAuthenticationError ||
error instanceof GoogleAdsValidationError) {
return false;
}
// Retry server errors (5xx) but not client errors (4xx)
if (httpCode >= 500) {
return true;
}
return false;
}
/**
* Calculate delay for exponential backoff
*/
calculateDelay(retryAttempt, config, retryAfter) {
// If rate limit error provides retry-after header, respect it
if (retryAfter && retryAfter > 0) {
return Math.min(retryAfter * 1000, config.maxDelayMs);
}
// Exponential backoff: baseDelay * (2 ^ retryAttempt) + jitter
const exponentialDelay = config.baseDelayMs * Math.pow(2, retryAttempt);
// Add jitter to prevent thundering herd (±25% random variation)
const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1);
const delayWithJitter = exponentialDelay + jitter;
// Cap at maximum delay
return Math.min(Math.max(delayWithJitter, config.baseDelayMs), config.maxDelayMs);
}
/**
* Execute function with retry logic
*/
async executeWithRetry(executeFunctions, operation, context, debugMode = false) {
const config = this.getRetryConfig();
let lastError;
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
try {
if (debugMode && attempt > 0) {
executeFunctions.logger.debug(`${context}: Retry attempt ${attempt}/${config.maxRetries}`);
}
const result = await operation();
if (debugMode && attempt > 0) {
executeFunctions.logger.debug(`${context}: Retry successful on attempt ${attempt}`);
}
return result;
}
catch (error) {
lastError = error;
// Parse the error using our error handling system
const parsedError = this.parseApiError(error, executeFunctions);
// Check if we should retry this error
if (!this.shouldRetry(error, attempt, config)) {
if (debugMode) {
executeFunctions.logger.debug(`${context}: Error not retryable`, {
error: parsedError.message,
errorType: parsedError.name,
attempt,
httpCode: error.httpCode || error.status,
});
}
throw parsedError;
}
// If this is our last attempt, throw the error
if (attempt === config.maxRetries) {
if (debugMode) {
executeFunctions.logger.debug(`${context}: Max retries exceeded`, {
error: parsedError.message,
errorType: parsedError.name,
maxRetries: config.maxRetries,
});
}
throw parsedError;
}
// Calculate delay for next attempt
const retryAfter = parsedError.retryAfter;
const delay = this.calculateDelay(attempt, config, retryAfter);
if (debugMode) {
executeFunctions.logger.debug(`${context}: Retrying after delay`, {
error: parsedError.message,
errorType: parsedError.name,
attempt: attempt + 1,
maxRetries: config.maxRetries,
delayMs: delay,
retryAfter: retryAfter,
});
}
// Wait before retrying
await this.sleep(delay);
}
}
// This should never be reached, but just in case
throw lastError;
}
/**
* Parse and categorize Google Ads API errors
*/
parseApiError(error, executeFunctions) {
const httpCode = error.httpCode || error.status || 0;
const message = error.message || 'Unknown error occurred';
const responseBody = error.response?.body || error.body;
// Log detailed error information to console for debugging
console.error('Google Ads API Error Details:', {
httpCode,
message,
responseBody: responseBody ? JSON.stringify(responseBody, null, 2) : undefined,
requestUrl: error.config?.url || error.url || 'Unknown URL',
requestMethod: error.config?.method || error.method || 'Unknown Method',
requestHeaders: error.config?.headers
? {
...error.config.headers,
'developer-token': error.config.headers['developer-token'] ? '***HIDDEN***' : 'MISSING',
}
: 'No headers available',
requestBody: error.config?.data || error.config?.body
? JSON.stringify(error.config?.data || error.config?.body, null, 2)
: 'No request body available',
stack: error.stack,
fullError: error,
});
// Check for URL-related errors first
if (message.includes('ERR_INVALID_URL') || message.includes('Invalid URL')) {
executeFunctions.logger.error('URL validation error:', {
message,
stack: error.stack,
});
return new GoogleAdsApiError(executeFunctions.getNode(), `Invalid URL: ${message}. Please check your customer ID format and ensure it contains only valid characters.`, 400, 'ERR_INVALID_URL');
}
// Log full error details in debug mode
executeFunctions.logger.debug('Google Ads API Error Details:', {
httpCode,
message,
responseBody,
stack: error.stack,
});
// Parse Google Ads specific error details if available
let apiErrorCode;
let detailedMessage = message;
let googleAdsErrors = [];
if (responseBody) {
try {
const errorData = typeof responseBody === 'string' ? JSON.parse(responseBody) : responseBody;
console.error('Parsed Google Ads Error Response:', {
fullErrorData: errorData,
hasError: !!errorData.error,
hasDetails: !!errorData.error?.details,
errorStructure: errorData.error ? Object.keys(errorData.error) : 'No error object',
});
// Extract Google Ads error details
if (errorData.error) {
apiErrorCode = errorData.error.code || errorData.error.status;
detailedMessage = errorData.error.message || message;
// Handle Google Ads specific error details structure
if (errorData.error.details) {
const details = Array.isArray(errorData.error.details)
? errorData.error.details
: [errorData.error.details];
// Extract specific Google Ads errors
for (const detail of details) {
if (detail.errors && Array.isArray(detail.errors)) {
googleAdsErrors.push(...detail.errors);
}
else if (detail.googleAdsFailure && detail.googleAdsFailure.errors) {
googleAdsErrors.push(...detail.googleAdsFailure.errors);
}
else if (detail.message) {
googleAdsErrors.push({ message: detail.message });
}
}
if (googleAdsErrors.length > 0) {
const errorMessages = googleAdsErrors
.map((err) => {
let errorMsg = err.message || err.errorCode || 'Unknown error';
if (err.location && err.location.fieldPath) {
errorMsg += ` (Field: ${err.location.fieldPath})`;
}
if (err.trigger && err.trigger.stringValue) {
errorMsg += ` (Value: ${err.trigger.stringValue})`;
}
return errorMsg;
})
.join('; ');
detailedMessage = `${detailedMessage}. Specific errors: ${errorMessages}`;
}
else {
const errorDetails = details
.map((detail) => detail.message || detail)
.join('; ');
detailedMessage += ` Details: ${errorDetails}`;
}
}
}
// Log structured Google Ads errors for debugging
if (googleAdsErrors.length > 0) {
console.error('Google Ads Specific Errors:', {
totalErrors: googleAdsErrors.length,
errors: googleAdsErrors.map((err) => ({
errorCode: err.errorCode,
message: err.message,
fieldPath: err.location?.fieldPath,
triggerValue: err.trigger?.stringValue,
fullError: err,
})),
});
}
}
catch (parseError) {
executeFunctions.logger.debug('Failed to parse error response body:', parseError);
console.error('Failed to parse error response body:', parseError);
console.error('Raw response body that failed to parse:', responseBody);
}
}
// Categorize errors based on HTTP status codes
switch (httpCode) {
case 400:
// Provide more specific guidance for 400 errors
let validationMessage = `Invalid request parameters. ${detailedMessage}`;
if (googleAdsErrors.length > 0) {
// Check for common error patterns and provide specific guidance
const fieldErrors = googleAdsErrors.filter((err) => err.location?.fieldPath);
if (fieldErrors.length > 0) {
const fieldsWithErrors = fieldErrors.map((err) => err.location.fieldPath).join(', ');
validationMessage += ` Check these fields: ${fieldsWithErrors}`;
}
// Check for conversion action errors
const conversionActionErrors = googleAdsErrors.filter((err) => err.message?.includes('conversion_action') ||
err.location?.fieldPath?.includes('conversion_action'));
if (conversionActionErrors.length > 0) {
validationMessage += ` Verify your conversion action ID is correct and accessible.`;
}
// Check for customer ID errors
const customerIdErrors = googleAdsErrors.filter((err) => err.message?.includes('customer') || err.message?.includes('login-customer-id'));
if (customerIdErrors.length > 0) {
validationMessage += ` Verify your customer ID and login-customer-id settings.`;
}
}
else {
validationMessage += ` Please check your conversion data, identifiers, customer ID, and conversion action format.`;
}
return new GoogleAdsValidationError(executeFunctions.getNode(), validationMessage);
case 401:
return new GoogleAdsAuthenticationError(executeFunctions.getNode(), `Authentication failed. ${detailedMessage}. Please verify your OAuth2 credentials and developer token.`);
case 403:
// Add basic permission guidance for 403 errors
let permissionMessage = `Access denied. ${detailedMessage}. Please verify your developer token permissions and customer ID access.`;
// Add basic guidance based on common 403 issues
permissionMessage += `\n\nCommon causes:\n• Account Type mismatch (check if you should use "Manager Account" vs "Regular Account")\n• Developer token lacks access to the target customer ID\n• OAuth credentials don't have proper scopes or permissions\n• Customer ID is incorrect or inaccessible\n• Conversion action belongs to a different account`;
console.error('Google Ads 403 Error - Permission Denied:', {
detailedMessage,
guidance: 'Check account type configuration and developer token permissions',
});
return new GoogleAdsAuthenticationError(executeFunctions.getNode(), permissionMessage);
case 404:
return new GoogleAdsValidationError(executeFunctions.getNode(), `Resource not found. ${detailedMessage}. Please check your conversion action ID and customer ID.`);
case 429:
// Extract retry-after header if available
const retryAfter = error.response?.headers?.['retry-after']
? parseInt(error.response.headers['retry-after'])
: undefined;
return new GoogleAdsRateLimitError(executeFunctions.getNode(), `Rate limit exceeded. ${detailedMessage}. Please implement retry logic or reduce request frequency.`, retryAfter);
case 500:
case 502:
case 503:
case 504:
return new GoogleAdsApiError(executeFunctions.getNode(), `Google Ads API server error (${httpCode}). ${detailedMessage}. Please try again later.`, httpCode, apiErrorCode);
default:
return new GoogleAdsApiError(executeFunctions.getNode(), `Unexpected error (${httpCode}): ${detailedMessage}`, httpCode, apiErrorCode);
}
}
/**
* Convert n8n DateTime objects or strings to ISO string format
*/
convertDateTimeToString(dateTimeValue) {
try {
// If null or undefined, use current time
if (!dateTimeValue) {
return new Date().toISOString();
}
// If it's already a string, validate and return
if (typeof dateTimeValue === 'string') {
// Basic validation: try to parse the string
const parsed = new Date(dateTimeValue);
if (!isNaN(parsed.getTime())) {
// Convert to Google Ads expected format: YYYY-MM-DD HH:mm:ss+TZ
return this.formatDateForGoogleAds(parsed);
}
// If string is invalid, fall back to current time
return this.formatDateForGoogleAds(new Date());
}
// If it's an array, use the first item
if (Array.isArray(dateTimeValue)) {
if (dateTimeValue.length > 0) {
return this.convertDateTimeToString(dateTimeValue[0]);
}
// Empty array, use current time
return this.formatDateForGoogleAds(new Date());
}
// Handle n8n DateTime objects and other objects
if (typeof dateTimeValue === 'object') {
// Check if it's a Date object
if (dateTimeValue instanceof Date) {
if (!isNaN(dateTimeValue.getTime())) {