Files
PriceUpdaterAppv2/src/utils/logger.js

799 lines
25 KiB
JavaScript

const ProgressService = require("../services/progress");
class Logger {
constructor(progressService = null) {
this.progressService = progressService || new ProgressService();
this.colors = {
reset: "\x1b[0m",
bright: "\x1b[1m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
cyan: "\x1b[36m",
};
}
/**
* Formats a timestamp for console display
* @param {Date} date - The date to format
* @returns {string} Formatted timestamp string
*/
formatTimestamp(date = new Date()) {
return date
.toISOString()
.replace("T", " ")
.replace(/\.\d{3}Z$/, "");
}
/**
* Formats a console message with color and timestamp
* @param {string} level - Log level (INFO, WARN, ERROR)
* @param {string} message - Message to log
* @param {string} color - ANSI color code
* @returns {string} Formatted message
*/
formatConsoleMessage(level, message, color) {
const timestamp = this.formatTimestamp();
return `${color}[${timestamp}] ${level}:${this.colors.reset} ${message}`;
}
/**
* Logs an info message to console
* @param {string} message - Message to log
* @returns {Promise<void>}
*/
async info(message) {
const formattedMessage = this.formatConsoleMessage(
"INFO",
message,
this.colors.cyan
);
console.log(formattedMessage);
}
/**
* Logs a warning message to console
* @param {string} message - Message to log
* @returns {Promise<void>}
*/
async warning(message) {
const formattedMessage = this.formatConsoleMessage(
"WARN",
message,
this.colors.yellow
);
console.warn(formattedMessage);
}
/**
* Logs an error message to console
* @param {string} message - Message to log
* @returns {Promise<void>}
*/
async error(message) {
const formattedMessage = this.formatConsoleMessage(
"ERROR",
message,
this.colors.red
);
console.error(formattedMessage);
}
/**
* Logs operation start with configuration details (Requirement 3.1)
* @param {Object} config - Configuration object
* @param {Object} schedulingContext - Optional scheduling context
* @returns {Promise<void>}
*/
async logOperationStart(config, schedulingContext = null) {
if (schedulingContext && schedulingContext.isScheduled) {
await this.info(
`Starting scheduled price update operation with configuration:`
);
await this.info(
` Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}`
);
await this.info(
` Original Schedule Input: ${schedulingContext.originalInput}`
);
} else {
await this.info(`Starting price update operation with configuration:`);
}
await this.info(` Target Tag: ${config.targetTag}`);
await this.info(` Price Adjustment: ${config.priceAdjustmentPercentage}%`);
await this.info(` Shop Domain: ${config.shopDomain}`);
// Also log to progress file with scheduling context
await this.progressService.logOperationStart(config, schedulingContext);
}
/**
* Logs rollback operation start with configuration details (Requirements 3.1, 7.1, 8.3)
* @param {Object} config - Configuration object
* @param {Object} schedulingContext - Optional scheduling context
* @returns {Promise<void>}
*/
async logRollbackStart(config, schedulingContext = null) {
if (schedulingContext && schedulingContext.isScheduled) {
await this.info(
`Starting scheduled price rollback operation with configuration:`
);
await this.info(
` Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}`
);
await this.info(
` Original Schedule Input: ${schedulingContext.originalInput}`
);
} else {
await this.info(`Starting price rollback operation with configuration:`);
}
await this.info(` Target Tag: ${config.targetTag}`);
await this.info(` Operation Mode: rollback`);
await this.info(` Shop Domain: ${config.shopDomain}`);
// Also log to progress file with rollback-specific format and scheduling context
try {
await this.progressService.logRollbackStart(config, schedulingContext);
} catch (error) {
// Progress logging should not block main operations
console.warn(`Warning: Failed to log to progress file: ${error.message}`);
}
}
/**
* Logs product count information (Requirement 3.2)
* @param {number} count - Number of matching products found
* @returns {Promise<void>}
*/
async logProductCount(count) {
const message = `Found ${count} product${
count !== 1 ? "s" : ""
} matching the specified tag`;
await this.info(message);
}
/**
* Logs individual product update details (Requirement 3.3)
* @param {Object} entry - Product update entry
* @param {string} entry.productTitle - Product title
* @param {string} entry.productId - Product ID
* @param {string} entry.variantId - Variant ID
* @param {number} entry.oldPrice - Original price
* @param {number} entry.newPrice - Updated price
* @returns {Promise<void>}
*/
async logProductUpdate(entry) {
const compareAtInfo = entry.compareAtPrice
? ` (Compare At: $${entry.compareAtPrice})`
: "";
const message = `${this.colors.green}${this.colors.reset} Updated "${entry.productTitle}" - Price: $${entry.oldPrice}$${entry.newPrice}${compareAtInfo}`;
console.log(message);
// Also log to progress file
await this.progressService.logProductUpdate(entry);
}
/**
* Logs successful rollback operations (Requirements 3.3, 7.2, 8.3)
* @param {Object} entry - Rollback update entry
* @param {string} entry.productTitle - Product title
* @param {string} entry.productId - Product ID
* @param {string} entry.variantId - Variant ID
* @param {number} entry.oldPrice - Original price before rollback
* @param {number} entry.compareAtPrice - Compare-at price being used as new price
* @param {number} entry.newPrice - New price (same as compare-at price)
* @returns {Promise<void>}
*/
async logRollbackUpdate(entry) {
const message = `${this.colors.green}🔄${this.colors.reset} Rolled back "${entry.productTitle}" - Price: ${entry.oldPrice}${entry.newPrice} (from Compare At: ${entry.compareAtPrice})`;
console.log(message);
// Also log to progress file with rollback-specific format
try {
await this.progressService.logRollbackUpdate(entry);
} catch (error) {
// Progress logging should not block main operations
console.warn(`Warning: Failed to log to progress file: ${error.message}`);
}
}
/**
* Logs completion summary (Requirement 3.4)
* @param {Object} summary - Summary statistics
* @param {number} summary.totalProducts - Total products processed
* @param {number} summary.successfulUpdates - Successful updates
* @param {number} summary.failedUpdates - Failed updates
* @param {Date} summary.startTime - Operation start time
* @returns {Promise<void>}
*/
async logCompletionSummary(summary) {
await this.info("=".repeat(50));
await this.info("OPERATION COMPLETE");
await this.info("=".repeat(50));
await this.info(`Total Products Processed: ${summary.totalProducts}`);
await this.info(
`Successful Updates: ${this.colors.green}${summary.successfulUpdates}${this.colors.reset}`
);
if (summary.failedUpdates > 0) {
await this.info(
`Failed Updates: ${this.colors.red}${summary.failedUpdates}${this.colors.reset}`
);
} else {
await this.info(`Failed Updates: ${summary.failedUpdates}`);
}
if (summary.startTime) {
const duration = Math.round((new Date() - summary.startTime) / 1000);
await this.info(`Duration: ${duration} seconds`);
}
// Also log to progress file
await this.progressService.logCompletionSummary(summary);
}
/**
* Logs rollback completion summary (Requirements 3.5, 7.3, 8.3)
* @param {Object} summary - Rollback summary statistics
* @param {number} summary.totalProducts - Total products processed
* @param {number} summary.totalVariants - Total variants processed
* @param {number} summary.eligibleVariants - Variants eligible for rollback
* @param {number} summary.successfulRollbacks - Successful rollback operations
* @param {number} summary.failedRollbacks - Failed rollback operations
* @param {number} summary.skippedVariants - Variants skipped (no compare-at price)
* @param {Date} summary.startTime - Operation start time
* @returns {Promise<void>}
*/
async logRollbackSummary(summary) {
await this.info("=".repeat(50));
await this.info("ROLLBACK OPERATION COMPLETE");
await this.info("=".repeat(50));
await this.info(`Total Products Processed: ${summary.totalProducts}`);
await this.info(`Total Variants Processed: ${summary.totalVariants}`);
await this.info(`Eligible Variants: ${summary.eligibleVariants}`);
await this.info(
`Successful Rollbacks: ${this.colors.green}${summary.successfulRollbacks}${this.colors.reset}`
);
if (summary.failedRollbacks > 0) {
await this.info(
`Failed Rollbacks: ${this.colors.red}${summary.failedRollbacks}${this.colors.reset}`
);
} else {
await this.info(`Failed Rollbacks: ${summary.failedRollbacks}`);
}
if (summary.skippedVariants > 0) {
await this.info(
`Skipped Variants: ${this.colors.yellow}${summary.skippedVariants}${this.colors.reset} (no compare-at price)`
);
} else {
await this.info(`Skipped Variants: ${summary.skippedVariants}`);
}
if (summary.startTime) {
const duration = Math.round((new Date() - summary.startTime) / 1000);
await this.info(`Duration: ${duration} seconds`);
}
// Also log to progress file with rollback-specific format
try {
await this.progressService.logRollbackSummary(summary);
} catch (error) {
// Progress logging should not block main operations
console.warn(`Warning: Failed to log to progress file: ${error.message}`);
}
}
/**
* Logs error details and continues processing (Requirement 3.5)
* @param {Object} entry - Error entry
* @param {string} entry.productTitle - Product title
* @param {string} entry.productId - Product ID
* @param {string} entry.variantId - Variant ID (optional)
* @param {string} entry.errorMessage - Error message
* @param {Object} schedulingContext - Optional scheduling context for error logging (Requirements 5.3, 5.4)
* @returns {Promise<void>}
*/
async logProductError(entry, schedulingContext = null) {
const variantInfo = entry.variantId ? ` (Variant: ${entry.variantId})` : "";
const schedulingInfo =
schedulingContext && schedulingContext.isScheduled
? ` [Scheduled Operation]`
: "";
const message = `${this.colors.red}${this.colors.reset} Failed to update "${entry.productTitle}"${variantInfo}: ${entry.errorMessage}${schedulingInfo}`;
console.error(message);
// Also log to progress file with scheduling context
await this.progressService.logError(entry, schedulingContext);
}
/**
* Logs API rate limiting information
* @param {number} retryAfter - Seconds to wait before retry
* @returns {Promise<void>}
*/
async logRateLimit(retryAfter) {
await this.warning(
`Rate limit encountered. Waiting ${retryAfter} seconds before retry...`
);
}
/**
* Logs network retry attempts
* @param {number} attempt - Current attempt number
* @param {number} maxAttempts - Maximum attempts
* @param {string} error - Error message
* @returns {Promise<void>}
*/
async logRetryAttempt(attempt, maxAttempts, error) {
await this.warning(
`Network error (attempt ${attempt}/${maxAttempts}): ${error}. Retrying...`
);
}
/**
* Logs when a product is skipped due to invalid data
* @param {string} productTitle - Product title
* @param {string} reason - Reason for skipping
* @returns {Promise<void>}
*/
async logSkippedProduct(productTitle, reason) {
await this.warning(`Skipped "${productTitle}": ${reason}`);
}
/**
* Logs scheduling confirmation with operation details (Requirements 2.1, 2.3)
* @param {Object} schedulingInfo - Scheduling information
* @param {Date} schedulingInfo.scheduledTime - Target execution time
* @param {string} schedulingInfo.originalInput - Original datetime input
* @param {string} schedulingInfo.operationType - Type of operation (update/rollback)
* @param {Object} schedulingInfo.config - Operation configuration
* @returns {Promise<void>}
*/
async logSchedulingConfirmation(schedulingInfo) {
const { scheduledTime, originalInput, operationType, config } =
schedulingInfo;
await this.info("=".repeat(50));
await this.info("SCHEDULED OPERATION CONFIRMED");
await this.info("=".repeat(50));
await this.info(`Operation Type: ${operationType}`);
await this.info(`Scheduled Time: ${scheduledTime.toLocaleString()}`);
await this.info(`Original Input: ${originalInput}`);
await this.info(`Target Tag: ${config.targetTag}`);
if (operationType === "update") {
await this.info(`Price Adjustment: ${config.priceAdjustmentPercentage}%`);
}
await this.info(`Shop Domain: ${config.shopDomain}`);
const delay = scheduledTime.getTime() - new Date().getTime();
const timeRemaining = this.formatTimeRemaining(delay);
await this.info(`Time Remaining: ${timeRemaining}`);
await this.info("Press Ctrl+C to cancel the scheduled operation");
await this.info("=".repeat(50));
// Also log to progress file
await this.progressService.logSchedulingConfirmation(schedulingInfo);
}
/**
* Logs countdown updates during scheduled wait period (Requirements 2.2, 2.3)
* @param {Object} countdownInfo - Countdown information
* @param {Date} countdownInfo.scheduledTime - Target execution time
* @param {number} countdownInfo.remainingMs - Milliseconds remaining
* @returns {Promise<void>}
*/
async logCountdownUpdate(countdownInfo) {
const { scheduledTime, remainingMs } = countdownInfo;
const timeRemaining = this.formatTimeRemaining(remainingMs);
await this.info(
`Scheduled execution in: ${timeRemaining} (at ${scheduledTime.toLocaleString()})`
);
}
/**
* Logs the start of scheduled operation execution (Requirements 2.3, 5.4)
* @param {Object} executionInfo - Execution information
* @param {Date} executionInfo.scheduledTime - Original scheduled time
* @param {Date} executionInfo.actualTime - Actual execution time
* @param {string} executionInfo.operationType - Type of operation
* @returns {Promise<void>}
*/
async logScheduledExecutionStart(executionInfo) {
const { scheduledTime, actualTime, operationType } = executionInfo;
const delay = actualTime.getTime() - scheduledTime.getTime();
const delayText =
Math.abs(delay) < 1000
? "on time"
: delay > 0
? `${Math.round(delay / 1000)}s late`
: `${Math.round(Math.abs(delay) / 1000)}s early`;
await this.info("=".repeat(50));
await this.info("SCHEDULED OPERATION STARTING");
await this.info("=".repeat(50));
await this.info(`Operation Type: ${operationType}`);
await this.info(`Scheduled Time: ${scheduledTime.toLocaleString()}`);
await this.info(`Actual Start Time: ${actualTime.toLocaleString()}`);
await this.info(`Timing: ${delayText}`);
await this.info("=".repeat(50));
// Also log to progress file
await this.progressService.logScheduledExecutionStart(executionInfo);
}
/**
* Logs scheduled operation cancellation (Requirements 3.1, 3.2)
* @param {Object} cancellationInfo - Cancellation information
* @param {Date} cancellationInfo.scheduledTime - Original scheduled time
* @param {Date} cancellationInfo.cancelledTime - Time when cancelled
* @param {string} cancellationInfo.operationType - Type of operation
* @param {string} cancellationInfo.reason - Cancellation reason
* @returns {Promise<void>}
*/
async logScheduledOperationCancellation(cancellationInfo) {
const { scheduledTime, cancelledTime, operationType, reason } =
cancellationInfo;
const timeRemaining = scheduledTime.getTime() - cancelledTime.getTime();
const remainingText = this.formatTimeRemaining(timeRemaining);
await this.info("=".repeat(50));
await this.info("SCHEDULED OPERATION CANCELLED");
await this.info("=".repeat(50));
await this.info(`Operation Type: ${operationType}`);
await this.info(`Scheduled Time: ${scheduledTime.toLocaleString()}`);
await this.info(`Cancelled Time: ${cancelledTime.toLocaleString()}`);
await this.info(`Time Remaining: ${remainingText}`);
await this.info(`Reason: ${reason}`);
await this.info("=".repeat(50));
// Also log to progress file
await this.progressService.logScheduledOperationCancellation(
cancellationInfo
);
}
/**
* Format time remaining into human-readable string
* @param {number} milliseconds - Time remaining in milliseconds
* @returns {string} Formatted time string (e.g., "2h 30m 15s")
*/
formatTimeRemaining(milliseconds) {
if (milliseconds <= 0) {
return "0s";
}
const seconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
const remainingMinutes = minutes % 60;
const remainingSeconds = seconds % 60;
const parts = [];
if (days > 0) parts.push(`${days}d`);
if (remainingHours > 0) parts.push(`${remainingHours}h`);
if (remainingMinutes > 0) parts.push(`${remainingMinutes}m`);
if (remainingSeconds > 0 || parts.length === 0)
parts.push(`${remainingSeconds}s`);
return parts.join(" ");
}
/**
* Logs comprehensive error analysis and recommendations
* @param {Array} errors - Array of error objects
* @param {Object} summary - Operation summary statistics
* @param {Object} schedulingContext - Optional scheduling context for error analysis (Requirements 5.3, 5.4)
* @returns {Promise<void>}
*/
async logErrorAnalysis(errors, summary, schedulingContext = null) {
if (!errors || errors.length === 0) {
return;
}
const operationType =
summary.successfulRollbacks !== undefined ? "ROLLBACK" : "UPDATE";
const schedulingPrefix =
schedulingContext && schedulingContext.isScheduled ? "SCHEDULED " : "";
await this.info("=".repeat(50));
await this.info(`${schedulingPrefix}${operationType} ERROR ANALYSIS`);
await this.info("=".repeat(50));
if (schedulingContext && schedulingContext.isScheduled) {
await this.info(
`Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}`
);
await this.info(
`Original Schedule Input: ${schedulingContext.originalInput}`
);
await this.info("=".repeat(50));
}
// Enhanced categorization for rollback operations
const categories = {};
const retryableErrors = [];
const nonRetryableErrors = [];
errors.forEach((error) => {
const category = this.categorizeError(
error.errorMessage || error.error || "Unknown"
);
if (!categories[category]) {
categories[category] = [];
}
categories[category].push(error);
// Track retryable vs non-retryable errors for rollback analysis
if (error.retryable === true) {
retryableErrors.push(error);
} else if (error.retryable === false) {
nonRetryableErrors.push(error);
}
});
// Display category breakdown
await this.info("Error Categories:");
Object.entries(categories).forEach(([category, categoryErrors]) => {
const percentage = (
(categoryErrors.length / errors.length) *
100
).toFixed(1);
this.info(
` ${category}: ${categoryErrors.length} errors (${percentage}%)`
);
});
// Rollback-specific error analysis (Requirements 4.3, 4.5)
if (operationType === "ROLLBACK") {
await this.info("\nRollback Error Analysis:");
if (retryableErrors.length > 0) {
await this.info(
` Retryable Errors: ${retryableErrors.length} (${(
(retryableErrors.length / errors.length) *
100
).toFixed(1)}%)`
);
}
if (nonRetryableErrors.length > 0) {
await this.info(
` Non-Retryable Errors: ${nonRetryableErrors.length} (${(
(nonRetryableErrors.length / errors.length) *
100
).toFixed(1)}%)`
);
}
// Analyze rollback-specific error patterns
const validationErrors = errors.filter(
(e) =>
e.errorType === "validation_error" || e.errorType === "validation"
);
if (validationErrors.length > 0) {
await this.info(
` Products without compare-at prices: ${validationErrors.length}`
);
}
const networkErrors = errors.filter(
(e) => e.errorType === "network_error"
);
if (networkErrors.length > 0) {
await this.info(
` Network-related failures: ${networkErrors.length} (consider retry)`
);
}
}
// Provide recommendations based on error patterns
await this.info("\nRecommendations:");
await this.provideErrorRecommendations(categories, summary, operationType);
// Log to progress file as well with scheduling context
await this.progressService.logErrorAnalysis(errors, schedulingContext);
}
/**
* Categorize error for analysis (same logic as progress service)
* @param {string} errorMessage - Error message to categorize
* @returns {string} Error category
*/
categorizeError(errorMessage) {
const message = errorMessage.toLowerCase();
if (
message.includes("rate limit") ||
message.includes("429") ||
message.includes("throttled")
) {
return "Rate Limiting";
}
if (
message.includes("network") ||
message.includes("connection") ||
message.includes("timeout")
) {
return "Network Issues";
}
if (
message.includes("authentication") ||
message.includes("unauthorized") ||
message.includes("401")
) {
return "Authentication";
}
if (
message.includes("permission") ||
message.includes("forbidden") ||
message.includes("403")
) {
return "Permissions";
}
if (message.includes("not found") || message.includes("404")) {
return "Resource Not Found";
}
if (
message.includes("validation") ||
message.includes("invalid") ||
message.includes("price")
) {
return "Data Validation";
}
if (
message.includes("server error") ||
message.includes("500") ||
message.includes("502") ||
message.includes("503")
) {
return "Server Errors";
}
if (message.includes("shopify") && message.includes("api")) {
return "Shopify API";
}
return "Other";
}
/**
* Provide recommendations based on error patterns
* @param {Object} categories - Categorized errors
* @param {Object} summary - Operation summary
* @param {string} operationType - Type of operation ('UPDATE' or 'ROLLBACK')
* @returns {Promise<void>}
*/
async provideErrorRecommendations(
categories,
summary,
operationType = "UPDATE"
) {
if (categories["Rate Limiting"]) {
await this.info(
" • Consider reducing batch size or adding delays between requests"
);
await this.info(
" • Check if your API plan supports the current request volume"
);
}
if (categories["Network Issues"]) {
await this.info(" • Check your internet connection stability");
await this.info(" • Consider running the script during off-peak hours");
if (operationType === "ROLLBACK") {
await this.info(
" • Network errors during rollback are retryable - consider re-running"
);
}
}
if (categories["Authentication"]) {
await this.info(
" • Verify your Shopify access token is valid and not expired"
);
await this.info(" • Check that your app has the required permissions");
}
if (categories["Data Validation"]) {
if (operationType === "ROLLBACK") {
await this.info(
" • Products without compare-at prices cannot be rolled back"
);
await this.info(
" • Consider filtering products to only include those with compare-at prices"
);
await this.info(
" • Review which products were updated in the original price adjustment"
);
} else {
await this.info(
" • Review product data for invalid prices or missing information"
);
await this.info(
" • Consider adding more robust data validation before updates"
);
}
}
if (categories["Server Errors"]) {
await this.info(" • Shopify may be experiencing temporary issues");
await this.info(" • Try running the script again later");
if (operationType === "ROLLBACK") {
await this.info(" • Server errors during rollback are retryable");
}
}
// Rollback-specific recommendations (Requirement 4.5)
if (operationType === "ROLLBACK") {
if (categories["Resource Not Found"]) {
await this.info(
" • Some products or variants may have been deleted since the original update"
);
await this.info(
" • Consider checking product existence before rollback operations"
);
}
if (categories["Permissions"]) {
await this.info(
" • Ensure your API credentials have product update permissions"
);
await this.info(
" • Rollback operations require the same permissions as price updates"
);
}
}
// Success rate analysis with operation-specific metrics
let successRate;
if (operationType === "ROLLBACK") {
successRate =
summary.eligibleVariants > 0
? (
(summary.successfulRollbacks / summary.eligibleVariants) *
100
).toFixed(1)
: 0;
} else {
successRate =
summary.totalVariants > 0
? ((summary.successfulUpdates / summary.totalVariants) * 100).toFixed(
1
)
: 0;
}
if (successRate < 50) {
await this.warning(
` • Low success rate detected (${successRate}%) - consider reviewing configuration`
);
if (operationType === "ROLLBACK") {
await this.warning(
" • Many products may not have valid compare-at prices for rollback"
);
}
} else if (successRate < 90) {
await this.info(
` • Moderate success rate (${successRate}%) - some optimization may be beneficial`
);
} else {
await this.info(
` • Good success rate (${successRate}%) - most operations completed successfully`
);
}
}
}
module.exports = Logger;