ak-fetch
Version:
Production-ready HTTP client for bulk operations with connection pooling, exponential backoff, streaming, and comprehensive error handling
741 lines (670 loc) âĸ 24.7 kB
JavaScript
/**
* Friendly logging system with progress tracking and throughput measurement
*
* @description
* Provides clean, informative output while respecting verbose settings.
* Features progress bars, timing, throughput calculations, and memory monitoring.
* Designed for command-line interfaces with real-time progress updates.
*
* @module Logger
* @since 2.0.0
* @version 2.0.0
*/
import readline from 'readline';
import { comma } from 'ak-tools';
/**
* Logger class for ak-fetch operations
*
* @description
* Main logger class that handles progress display, timing, and throughput calculations.
* Provides methods for different log levels, progress tracking, and formatted output.
* Respects verbose settings and provides clean CLI output.
*
* @class AkLogger
* @since 2.0.0
*/
class AkLogger {
constructor(options = {}) {
this.verbose = options.verbose !== false;
this.startTime = null;
this.lastProgressUpdate = 0;
this.progressUpdateInterval = options.progressUpdateInterval || 250; // ms
this.showThroughput = options.showThroughput !== false;
this.showMemory = options.showMemory || false;
this.progressBarWidth = options.progressBarWidth || 30;
this.logPrefix = options.logPrefix || 'đ';
this.progressActive = false;
// Store original console methods
this.originalLog = console.log;
this.originalError = console.error;
this.originalWarn = console.warn;
// Bind methods to preserve context
this.log = this.log.bind(this);
this.error = this.error.bind(this);
this.warn = this.warn.bind(this);
this.progress = this.progress.bind(this);
}
/**
* Start a new operation with initial logging
*
* @description
* Initializes timing and displays operation start message with configuration.
* Sets up progress tracking and logs relevant configuration details.
*
* @param {string} message - Operation description to display
* @description Brief description of what operation is starting
*
* @param {Object} [config={}] - Configuration details to display
* @param {string} [config.url] - Target URL
* @param {string} [config.method] - HTTP method
* @param {number} [config.batchSize] - Batch size
* @param {number} [config.concurrency] - Concurrency level
* @param {number} [config.retries] - Retry attempts
* @param {number} [config.timeout] - Request timeout
*
* @example
* logger.start('Processing bulk upload', {
* url: 'https://api.example.com/bulk',
* method: 'POST',
* batchSize: 100,
* concurrency: 10
* });
*
* @since 2.0.0
*/
start(message, config = {}) {
this.startTime = Date.now();
this.lastProgressUpdate = 0;
if (!this.verbose) return;
console.log(`\n${this.logPrefix} ${message}`);
if (config && Object.keys(config).length > 0) {
this.logConfig(config);
}
console.log(''); // Add spacing
}
/**
* Log configuration details in a friendly format
*
* @description
* Displays configuration settings in a clean, readable format.
* Shows key operational parameters and enabled features.
*
* @param {Object} config - Configuration object to display
* @param {string} [config.url] - Target URL
* @param {string} [config.method] - HTTP method
* @param {number} [config.batchSize] - Items per batch
* @param {number} [config.concurrency] - Concurrent requests
* @param {number} [config.retries] - Retry attempts
* @param {number} [config.timeout] - Request timeout
* @param {Array|string} [config.data] - Data source
*
* @example
* logger.logConfig({
* url: 'https://api.example.com',
* method: 'POST',
* batchSize: 50,
* concurrency: 5,
* enableCookies: true
* });
* // Outputs formatted configuration display
*
* @since 2.0.0
*/
logConfig(config) {
if (!this.verbose) return;
const {
url,
method = 'POST',
batchSize,
concurrency,
retries,
timeout,
data,
...otherConfig
} = config;
console.log('đ Configuration:');
console.log(` URL: ${url}`);
console.log(` Method: ${method.toUpperCase()}`);
if (Array.isArray(data)) {
console.log(` Records: ${comma(data.length)}`);
} else if (typeof data === 'string') {
console.log(` Data Source: ${data}`);
}
if (batchSize) {
console.log(` Batch Size: ${comma(batchSize)}`);
}
if (concurrency) {
console.log(` Concurrency: ${concurrency}`);
}
if (retries !== undefined) {
console.log(` Retries: ${retries === null ? 'Fire-and-forget' : retries}`);
}
if (timeout) {
console.log(` Timeout: ${this.formatDuration(timeout)}`);
}
// Log additional interesting config
const interestingKeys = ['enableCookies', 'enableConnectionPooling', 'useStaticRetryDelay'];
const additionalConfig = {};
interestingKeys.forEach(key => {
if (otherConfig[key] !== undefined) {
additionalConfig[key] = otherConfig[key];
}
});
if (Object.keys(additionalConfig).length > 0) {
console.log(` Features: ${Object.entries(additionalConfig)
.filter(([, value]) => value)
.map(([key]) => this.camelToTitle(key))
.join(', ')}`);
}
}
/**
* Update progress display with current statistics
*
* @description
* Updates the progress display with current statistics including completion
* percentage, throughput, and ETA. Throttles updates for performance.
*
* @param {number} completed - Number of completed items
* @description Usually represents completed batches or requests
*
* @param {number} [total=0] - Total number of items to process
* @description When 0, shows completed count without percentage
*
* @param {number} [records=0] - Number of individual records processed
* @description Distinct from batches, represents actual data items
*
* @param {Object} [options={}] - Additional progress options
* @description Reserved for future progress display options
*
* @example
* // Basic progress with percentage
* logger.progress(75, 100, 7500);
* // Displays: [ââââââââââââââââââââââââââââââ] 75% | 75/100 batches | 7,500 records | 25 req/s
*
* @example
* // Progress without total (streaming mode)
* logger.progress(150, 0, 15000);
* // Displays: [ââââââââââââââââââââââââââââââââ] 0% | 150 requests | 15,000 records | 30 req/s
*
* @since 2.0.0
*/
progress(completed, total = 0, records = 0, options = {}) {
if (!this.verbose) return;
const now = Date.now();
// Throttle progress updates for performance
if (now - this.lastProgressUpdate < this.progressUpdateInterval) {
return;
}
this.lastProgressUpdate = now;
this.progressActive = true;
// Clear current line and move cursor to beginning
readline.cursorTo(process.stdout, 0);
readline.clearLine(process.stdout, 0);
const percent = total > 0 ? Math.floor((completed / total) * 100) : 0;
const progressBar = this.createProgressBar(percent);
let message = `${progressBar} ${percent}%`;
if (total > 0) {
message += ` | ${comma(completed)}/${comma(total)} batches`;
} else {
message += ` | ${comma(completed)} requests`;
}
if (records > 0) {
message += ` | ${comma(records)} records`;
}
// Add throughput if enabled and we have timing data
if (this.showThroughput && this.startTime && completed > 0) {
const elapsed = (now - this.startTime) / 1000;
const rps = Math.floor(completed / elapsed);
if (rps > 0) {
message += ` | ${comma(rps)} req/s`;
}
if (records > 0) {
const recordsPerSec = Math.floor(records / elapsed);
if (recordsPerSec > 0) {
message += ` (${comma(recordsPerSec)} rec/s)`;
}
}
}
// Add memory usage if enabled
if (this.showMemory) {
const memUsage = process.memoryUsage();
const heapMB = Math.round(memUsage.heapUsed / 1024 / 1024);
message += ` | ${heapMB}MB`;
}
// Add ETA if we have enough data
if (this.startTime && total > 0 && completed > 0 && percent < 100) {
const elapsed = now - this.startTime;
const rate = completed / elapsed;
const remaining = total - completed;
const eta = remaining / rate;
if (eta > 0 && eta < 86400000) { // Less than 24 hours
message += ` | ETA: ${this.formatDuration(eta)}`;
}
}
process.stdout.write(` ${message}`);
}
/**
* Complete progress display and show final statistics
*
* @description
* Clears progress line and displays final operation statistics.
* Shows completion status, timing, throughput, and error counts.
*
* @param {Object} [results={}] - Final operation results
* @param {number} [results.reqCount] - Total requests made
* @param {number} [results.rowCount] - Total records processed
* @param {number} [results.duration] - Operation duration in ms
* @param {number} [results.rps] - Requests per second
* @param {number} [results.errors] - Number of errors
* @param {Object} [results.stats] - Memory statistics
*
* @example
* logger.complete({
* reqCount: 100,
* rowCount: 10000,
* duration: 30000,
* rps: 3.33,
* errors: 2
* });
* // Displays completion summary with all metrics
*
* @since 2.0.0
*/
complete(results = {}) {
if (!this.verbose) return;
// Clear progress line
readline.cursorTo(process.stdout, 0);
readline.clearLine(process.stdout, 0);
const {
reqCount = 0,
rowCount = 0,
duration = 0,
rps = 0,
errors = 0
} = results;
const emoji = errors > 0 ? 'â ī¸' : 'â
';
console.log(`${emoji} Completed: ${comma(reqCount)} requests`);
if (rowCount > 0) {
console.log(` đ Processed: ${comma(rowCount)} records`);
}
if (duration > 0) {
console.log(` âąī¸ Duration: ${this.formatDuration(duration)}`);
}
if (rps > 0) {
console.log(` đ Throughput: ${comma(rps)} requests/second`);
if (rowCount > 0) {
const recordsPerSec = Math.floor(rowCount / (duration / 1000));
console.log(` đ Records/sec: ${comma(recordsPerSec)}`);
}
}
if (errors > 0) {
console.log(` â Errors: ${comma(errors)}`);
}
// Show memory stats if enabled
if (this.showMemory && results.stats) {
console.log(` đž Memory: ${results.stats.heapUsed}MB heap, ${results.stats.rss}MB RSS`);
}
console.log(''); // Add spacing
}
/**
* Log general messages (respects verbose setting)
*
* @description
* Logs messages only when verbose mode is enabled. Uses standard console.log
* behavior for formatting and output.
*
* @param {...any} args - Arguments to log (same as console.log)
* @description Supports all console.log argument types and formatting
*
* @example
* logger.log('Processing started');
* logger.log('Found %d records in %s', count, filename);
* logger.log({ config: settings });
*
* @since 2.0.0
*/
log(...args) {
if (!this.verbose) return;
this.originalLog(...args);
}
/**
* Log error messages (always shown regardless of verbose setting)
*
* @description
* Logs error messages with error emoji prefix. Always displayed regardless
* of verbose setting since errors are critical information.
*
* @param {...any} args - Arguments to log (same as console.error)
* @description Supports all console.error argument types and formatting
*
* @example
* logger.error('Request failed:', error.message);
* logger.error('Network error: %s', networkError);
*
* @since 2.0.0
*/
error(...args) {
this.originalError('â', ...args);
}
/**
* Log warning messages (always shown regardless of verbose setting)
*
* @description
* Logs warning messages with warning emoji prefix. Always displayed
* regardless of verbose setting for important notifications.
*
* @param {...any} args - Arguments to log (same as console.warn)
* @description Supports all console.warn argument types and formatting
*
* @example
* logger.warn('Retrying request due to timeout');
* logger.warn('Rate limit approaching: %d/%d', current, limit);
*
* @since 2.0.0
*/
warn(...args) {
this.originalWarn('â ī¸', ...args);
}
/**
* Log informational messages with icon
*
* @description
* Logs informational messages with info emoji prefix. Only displayed
* when verbose mode is enabled.
*
* @param {...any} args - Arguments to log (same as console.log)
* @description Supports all console.log argument types and formatting
*
* @example
* logger.info('Connected to database');
* logger.info('Cache hit rate: %d%%', hitRate);
*
* @since 2.0.0
*/
info(...args) {
if (!this.verbose) return;
// If progress is active, clear the line first and add newline after
if (this.progressActive) {
readline.cursorTo(process.stdout, 0);
readline.clearLine(process.stdout, 0);
this.originalLog('âšī¸', ...args);
this.progressActive = false; // Reset progress state
} else {
this.originalLog('âšī¸', ...args);
}
}
/**
* Log success messages with icon
*
* @description
* Logs success messages with success emoji prefix. Only displayed
* when verbose mode is enabled.
*
* @param {...any} args - Arguments to log (same as console.log)
* @description Supports all console.log argument types and formatting
*
* @example
* logger.success('Upload completed successfully');
* logger.success('Processed %d records', recordCount);
*
* @since 2.0.0
*/
success(...args) {
if (!this.verbose) return;
this.originalLog('â
', ...args);
}
/**
* Log file operation messages
*
* @description
* Logs file operation messages with file emoji prefix. Shows operation
* type, filename, and optional format information.
*
* @param {string} operation - Operation type (Reading, Writing, etc.)
* @description Capitalized operation description like 'Writing', 'Reading'
*
* @param {string} filename - File name or path
* @description Full or relative path to the file being operated on
*
* @param {string} [format=''] - File format description
* @description Optional format like 'json', 'csv', 'ndjson'
*
* @example
* logger.fileOperation('Writing', './results.json', 'json');
* // Output: đ Writing: ./results.json (json)
*
* @example
* logger.fileOperation('Reading', '/data/input.csv');
* // Output: đ Reading: /data/input.csv
*
* @since 2.0.0
*/
fileOperation(operation, filename, format = '') {
if (!this.verbose) return;
const formatStr = format ? ` (${format})` : '';
this.originalLog(`đ ${operation}: ${filename}${formatStr}`);
}
/**
* Create ASCII progress bar
*
* @description
* Generates an ASCII progress bar using block characters. Creates visual
* representation of completion percentage with filled and empty sections.
*
* @param {number} percent - Percentage complete (0-100)
* @description Must be between 0 and 100, values outside range may produce unexpected results
*
* @returns {string} ASCII progress bar string
* @description Formatted progress bar like '[ââââââââââââââââââââââââââââââ]'
*
* @example
* logger.createProgressBar(75);
* // Returns: '[ââââââââââââââââââââââââââââââ]'
*
* @example
* logger.createProgressBar(0);
* // Returns: '[ââââââââââââââââââââââââââââââ]'
*
* @since 2.0.0
*/
createProgressBar(percent) {
const filled = Math.floor((percent / 100) * this.progressBarWidth);
const empty = this.progressBarWidth - filled;
const filledBar = 'â'.repeat(filled);
const emptyBar = 'â'.repeat(empty);
return `[${filledBar}${emptyBar}]`;
}
/**
* Format duration in milliseconds to human-readable string
*
* @description
* Converts millisecond durations into human-readable format with appropriate
* units. Automatically selects the most suitable unit (ms, s, m, h) based on duration.
*
* @param {number} ms - Duration in milliseconds
* @description Must be a non-negative number
*
* @returns {string} Formatted duration string
* @description Like '500ms', '2.5s', '3m 45s', '1h 30m'
*
* @example
* logger.formatDuration(500); // '500ms'
* logger.formatDuration(2500); // '2.5s'
* logger.formatDuration(90000); // '1m 30s'
* logger.formatDuration(3661000); // '1h 1m'
*
* @since 2.0.0
*/
formatDuration(ms) {
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
const seconds = ms / 1000;
if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (minutes < 60) {
return `${minutes}m ${remainingSeconds}s`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
/**
* Convert camelCase to Title Case
*
* @description
* Converts camelCase strings to Title Case for display purposes.
* Useful for converting configuration property names into readable labels.
*
* @param {string} str - camelCase string to convert
* @description String in camelCase format like 'enableCookies'
*
* @returns {string} Title Case string
* @description Converted string like 'Enable Cookies'
*
* @example
* logger.camelToTitle('enableCookies'); // 'Enable Cookies'
* logger.camelToTitle('useStaticRetryDelay'); // 'Use Static Retry Delay'
* logger.camelToTitle('maxRetries'); // 'Max Retries'
*
* @since 2.0.0
*/
camelToTitle(str) {
return str
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.trim();
}
/**
* Temporarily disable logging
*
* @description
* Disables verbose logging output. Error and warning messages will still
* be displayed. Can be re-enabled with unsilence().
*
* @example
* logger.silence();
* logger.log('This will not be displayed');
* logger.error('This will still be displayed');
*
* @since 2.0.0
*/
silence() {
this.verbose = false;
}
/**
* Re-enable logging
*
* @description
* Re-enables verbose logging output that was previously disabled with silence().
* Restores normal logging behavior.
*
* @example
* logger.silence();
* logger.unsilence();
* logger.log('This will be displayed again');
*
* @since 2.0.0
*/
unsilence() {
this.verbose = true;
}
/**
* Check if logger is in verbose mode
*
* @description
* Returns the current verbose state of the logger. Useful for conditional
* logging logic in calling code.
*
* @returns {boolean} True if verbose mode is enabled
* @description False if logging has been silenced or verbose was set to false
*
* @example
* if (logger.isVerbose()) {
* const details = generateDetailedReport();
* logger.log('Detailed report:', details);
* }
*
* @since 2.0.0
*/
isVerbose() {
return this.verbose;
}
/**
* Create a child logger with inherited settings
*
* @description
* Creates a new logger instance that inherits settings from the parent.
* Allows overriding specific options while maintaining other settings.
*
* @param {Object} [options={}] - Options to override from parent
* @param {boolean} [options.verbose] - Override verbose setting
* @param {boolean} [options.showThroughput] - Override throughput display
* @param {boolean} [options.showMemory] - Override memory display
* @param {number} [options.progressBarWidth] - Override progress bar width
* @param {string} [options.logPrefix] - Override log prefix emoji
*
* @returns {AkLogger} New logger instance with inherited settings
* @description Independent logger that can be configured separately
*
* @example
* const parentLogger = new AkLogger({ verbose: true, showMemory: true });
* const childLogger = parentLogger.child({ logPrefix: 'đ' });
* // Child inherits verbose: true, showMemory: true but uses different prefix
*
* @since 2.0.0
*/
child(options = {}) {
return new AkLogger({
verbose: this.verbose,
showThroughput: this.showThroughput,
showMemory: this.showMemory,
progressBarWidth: this.progressBarWidth,
logPrefix: this.logPrefix,
...options
});
}
}
/**
* Create a new logger instance
*
* @description
* Factory function to create a new AkLogger instance with specified options.
* Provides a convenient way to create loggers without using 'new' keyword.
*
* @param {Object} [options={}] - Logger configuration options
* @param {boolean} [options.verbose=true] - Enable verbose output
* @param {number} [options.progressUpdateInterval=250] - Progress update throttle (ms)
* @param {boolean} [options.showThroughput=true] - Show throughput in progress
* @param {boolean} [options.showMemory=false] - Show memory usage
* @param {number} [options.progressBarWidth=30] - Width of progress bar
* @param {string} [options.logPrefix='đ'] - Emoji prefix for operations
*
* @returns {AkLogger} Configured logger instance
* @description Ready-to-use logger with specified configuration
*
* @example
* const logger = createLogger({
* verbose: true,
* showThroughput: true,
* showMemory: true,
* logPrefix: 'đĄ'
* });
*
* @example
* // Simple logger with defaults
* const logger = createLogger();
* logger.start('Processing data');
*
* @since 2.0.0
*/
function createLogger(options = {}) {
return new AkLogger(options);
}
export {
AkLogger,
createLogger
};