diff --git a/assets/README.md b/assets/README.md deleted file mode 100644 index 8e9ad77..0000000 --- a/assets/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# 🎨 Assets Directory - -This folder contains visual assets for the Microsoft Rewards Script project. - -## 📁 Contents - -### `logo.png` -**The official project mascot and logo** - -- **Dimensions:** Original size preserved -- **Format:** PNG with transparency -- **Usage:** - - README.md header (200px width) - - Documentation pages (120-150px width) - - Social media and promotional materials - -## 📐 Logo Usage Guidelines - -### Recommended Sizes - -| Context | Recommended Width | Example | -|---------|------------------|---------| -| Main README | 200px | `` | -| Documentation pages | 120-150px | `` | -| GitHub social preview | 1200x630px | Resize as needed | -| Favicon | 32x32px or 64x64px | Convert to ICO format | - -### Usage Examples - -**In README.md (root):** -```markdown -
-Microsoft Rewards Script Logo -
-``` - -**In docs/*.md files:** -```markdown -
-Microsoft Rewards Script Logo -
-``` - -**In HTML:** -```html -Microsoft Rewards Script Logo -``` - -## 🎨 Design Notes - -The logo represents the project's mascot and serves as the visual identity for: -- Documentation headers -- Community presence (Discord, etc.) -- Project branding -- Social media - -## 📝 Attribution - -Logo created for the Microsoft Rewards Script project. - -## 🔒 Usage Rights - -This logo is part of the Microsoft Rewards Script project and follows the same [LICENSE](../LICENSE) as the project code. - ---- - -## 🖼️ Future Assets - -This directory may be expanded to include: -- Screenshots for documentation -- Diagrams and flowcharts -- Favicon files -- Social media banners -- Tutorial images - ---- - -
- -**[← Back to README](../README.md)** - -
diff --git a/assets/banner.png b/assets/banner.png index 9c2aa6a..bafd05f 100644 Binary files a/assets/banner.png and b/assets/banner.png differ diff --git a/assets/logo.png b/assets/logo.png index 9a7ead0..bafd05f 100644 Binary files a/assets/logo.png and b/assets/logo.png differ diff --git a/src/flows/SummaryReporter.ts b/src/flows/SummaryReporter.ts index 81e419c..0c93fec 100644 --- a/src/flows/SummaryReporter.ts +++ b/src/flows/SummaryReporter.ts @@ -13,6 +13,7 @@ import type { Config } from '../interface/Config' import { ConclusionWebhook } from '../util/notifications/ConclusionWebhook' import { log } from '../util/notifications/Logger' import { Ntfy } from '../util/notifications/Ntfy' +import { getActivityStatsTracker, resetActivityStatsTracker } from '../util/state/ActivityStatsTracker' import { JobState } from '../util/state/JobState' export interface AccountResult { @@ -258,12 +259,52 @@ export class SummaryReporter { log('main', 'SUMMARY', '═'.repeat(80)) + // Log activity statistics + this.logActivityStats() + // Send notifications await Promise.all([ this.sendWebhookSummary(summary), this.sendPushNotification(summary), this.updateJobState(summary) ]) + + // Reset activity stats for next run + resetActivityStatsTracker() + } + + /** + * Log activity success/failure statistics + */ + private logActivityStats(): void { + const tracker = getActivityStatsTracker() + const summary = tracker.getSummary() + + if (summary.totalAttempts === 0) { + return + } + + log('main', 'SUMMARY', '─'.repeat(80)) + log('main', 'SUMMARY', '📈 Activity Statistics:') + log('main', 'SUMMARY', ` Total: ${summary.totalSuccesses}/${summary.totalAttempts} succeeded (${(summary.overallSuccessRate * 100).toFixed(1)}%)`) + + // Show per-activity breakdown if there are multiple activity types + if (summary.byActivity.length > 1) { + for (const activity of summary.byActivity) { + const rate = (activity.successRate * 100).toFixed(0) + const avgTime = (activity.avgDurationMs / 1000).toFixed(1) + log('main', 'SUMMARY', ` ${activity.type}: ${activity.successes}/${activity.attempts} (${rate}%) avg ${avgTime}s`) + } + } + + // Warn about problematic activities + const problematic = tracker.getProblematicActivities() + if (problematic.length > 0) { + log('main', 'SUMMARY', '⚠️ High Failure Activities:', 'warn') + for (const p of problematic) { + log('main', 'SUMMARY', ` ${p.type}: ${(p.failureRate * 100).toFixed(0)}% failure rate (${p.attempts} attempts)`, 'warn') + } + } } /** diff --git a/src/functions/Workers.ts b/src/functions/Workers.ts index cb1b4d5..8faa29e 100644 --- a/src/functions/Workers.ts +++ b/src/functions/Workers.ts @@ -8,6 +8,7 @@ import { waitForElementSmart, waitForNetworkIdle } from '../util/browser/SmartWa import { Retry } from '../util/core/Retry' import { AdaptiveThrottler } from '../util/notifications/AdaptiveThrottler' import { logError } from '../util/notifications/Logger' +import { getActivityStatsTracker } from '../util/state/ActivityStatsTracker' import JobState from '../util/state/JobState' // Selector patterns (extracted to avoid magic strings) @@ -267,7 +268,11 @@ export class Workers { } private async executeActivity(page: Page, activity: PromotionalItem | MorePromotion, selector: string, throttle: AdaptiveThrottler, retry: Retry): Promise { - this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "${this.bot.activities.getTypeLabel(activity)}" title: "${activity.title}"`) + const activityType = this.bot.activities.getTypeLabel(activity) + const statsTracker = getActivityStatsTracker() + const startTime = statsTracker.startActivity(activityType) + + this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "${activityType}" title: "${activity.title}"`) // IMPROVED: Fast-fail for unavailable activities (1s+3s instead of 2s+5s) const elementResult = await waitForElementSmart(page, selector, { @@ -279,6 +284,7 @@ export class Workers { if (!elementResult.found) { this.bot.log(this.bot.isMobile, 'ACTIVITY', `[SKIP] Activity not available: "${activity.title}" (already completed or not offered today)`) + statsTracker.recordSuccess(activityType, startTime) // Count as success (nothing to do) return // Skip this activity gracefully } @@ -294,6 +300,7 @@ export class Workers { } catch (clickError) { const errMsg = clickError instanceof Error ? clickError.message : String(clickError) this.bot.log(this.bot.isMobile, 'ACTIVITY', `Failed to click activity: ${errMsg}`, 'error') + statsTracker.recordFailure(activityType, startTime, clickError instanceof Error ? clickError : new Error(errMsg)) throw new Error(`Activity click failed: ${errMsg}`) } @@ -302,24 +309,31 @@ export class Workers { // Execute activity with timeout protection using Promise.race const timeoutMs = this.bot.utils.stringToMs(this.bot.config?.globalTimeout ?? '30s') * 2 - await retry.run(async () => { - const activityPromise = this.bot.activities.run(page, activity) - const timeoutPromise = new Promise((_, reject) => { - const timer = setTimeout(() => { - reject(new Error(`Activity execution timeout after ${timeoutMs}ms`)) - }, timeoutMs) - // Clean up timer if activity completes first - activityPromise.finally(() => clearTimeout(timer)) - }) + try { + await retry.run(async () => { + const activityPromise = this.bot.activities.run(page, activity) + const timeoutPromise = new Promise((_, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Activity execution timeout after ${timeoutMs}ms`)) + }, timeoutMs) + // Clean up timer if activity completes first + activityPromise.finally(() => clearTimeout(timer)) + }) - try { - await Promise.race([activityPromise, timeoutPromise]) - throttle.record(true) - } catch (e) { - throttle.record(false) - throw e - } - }, () => true) + try { + await Promise.race([activityPromise, timeoutPromise]) + throttle.record(true) + } catch (e) { + throttle.record(false) + throw e + } + }, () => true) + + statsTracker.recordSuccess(activityType, startTime) + } catch (activityError) { + statsTracker.recordFailure(activityType, startTime, activityError instanceof Error ? activityError : undefined) + throw activityError + } await this.bot.browser.utils.humanizePage(page) } diff --git a/src/index.ts b/src/index.ts index 9067a0d..736b3e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { createInterface } from 'readline' import BrowserFunc from './browser/BrowserFunc' import BrowserUtil from './browser/BrowserUtil' import Humanizer from './util/browser/Humanizer' +import { getMemoryMonitor, stopMemoryMonitor } from './util/core/MemoryMonitor' import { formatDetailedError, normalizeRecoveryEmail, shortErrorMessage, Util } from './util/core/Utils' import Axios from './util/network/Axios' import { QueryDiversityEngine } from './util/network/QueryDiversityEngine' @@ -1059,18 +1060,21 @@ async function main(): Promise { log('main', 'FATAL', `UncaughtException: ${err.message}${err.stack ? `\nStack: ${err.stack.split('\n').slice(0, 3).join(' | ')}` : ''}`, 'error') scheduler?.stop() // Stop scheduler before exit stopWebhookCleanup() + stopMemoryMonitor() // Stop memory monitoring before exit gracefulExit(1) }) process.on('SIGTERM', () => { log('main', 'SHUTDOWN', 'Received SIGTERM, shutting down gracefully...', 'log') scheduler?.stop() // Stop scheduler before exit stopWebhookCleanup() + stopMemoryMonitor() // Stop memory monitoring before exit gracefulExit(0) }) process.on('SIGINT', () => { log('main', 'SHUTDOWN', 'Received SIGINT (Ctrl+C), shutting down gracefully...', 'log') scheduler?.stop() // Stop scheduler before exit stopWebhookCleanup() + stopMemoryMonitor() // Stop memory monitoring before exit gracefulExit(0) }) } @@ -1178,6 +1182,15 @@ async function main(): Promise { // This gives users instant confirmation of the cron schedule without waiting for long execution log('main', 'MAIN', 'Scheduling enabled - activating scheduler, then executing immediate run', 'log', 'cyan') + // Start memory monitoring for long-running scheduled sessions + const memoryMonitor = getMemoryMonitor({ + warningThresholdMB: 500, + criticalThresholdMB: 1024, + leakRateMBPerHour: 50, + samplingIntervalMs: 60000 // Sample every minute + }) + memoryMonitor.start() + // Initialize and start scheduler first scheduler = new InternalScheduler(config, async () => { try { diff --git a/src/util/core/MemoryMonitor.ts b/src/util/core/MemoryMonitor.ts new file mode 100644 index 0000000..7f49345 --- /dev/null +++ b/src/util/core/MemoryMonitor.ts @@ -0,0 +1,268 @@ +/** + * Memory monitoring utility for long-running bot sessions + * Tracks heap usage and warns on potential memory leaks + */ + +import { log } from '../notifications/Logger' + +interface MemorySnapshot { + timestamp: number + heapUsedMB: number + heapTotalMB: number + externalMB: number + rssMB: number +} + +interface MemoryConfig { + /** Threshold in MB to trigger warning (default: 500MB) */ + warningThresholdMB?: number + /** Threshold in MB to trigger critical alert (default: 1024MB) */ + criticalThresholdMB?: number + /** Minimum growth rate (MB/hour) to consider a leak (default: 50MB/hour) */ + leakRateMBPerHour?: number + /** Sampling interval in milliseconds (default: 60000 = 1 minute) */ + samplingIntervalMs?: number + /** Number of samples to keep for trend analysis (default: 60) */ + maxSamples?: number +} + +const DEFAULT_CONFIG: Required = { + warningThresholdMB: 500, + criticalThresholdMB: 1024, + leakRateMBPerHour: 50, + samplingIntervalMs: 60000, + maxSamples: 60 +} + +export class MemoryMonitor { + private config: Required + private samples: MemorySnapshot[] = [] + private intervalId: NodeJS.Timeout | null = null + private startTime: number = Date.now() + private warningEmitted: boolean = false + private criticalEmitted: boolean = false + + constructor(config?: MemoryConfig) { + this.config = { ...DEFAULT_CONFIG, ...config } + } + + /** + * Start monitoring memory usage at configured interval + */ + start(): void { + if (this.intervalId) { + return // Already running + } + + this.startTime = Date.now() + this.samples = [] + this.warningEmitted = false + this.criticalEmitted = false + + // Take initial sample + this.takeSample() + + // Schedule periodic sampling + this.intervalId = setInterval(() => { + this.takeSample() + this.analyzeMemory() + }, this.config.samplingIntervalMs) + + log('main', 'MEMORY', `Memory monitoring started (warning: ${this.config.warningThresholdMB}MB, critical: ${this.config.criticalThresholdMB}MB)`) + } + + /** + * Stop monitoring and clear resources + */ + stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId) + this.intervalId = null + } + + // Log final summary + if (this.samples.length > 0) { + const summary = this.getSummary() + log('main', 'MEMORY', `Monitor stopped. Peak: ${summary.peakHeapMB.toFixed(1)}MB, Avg: ${summary.avgHeapMB.toFixed(1)}MB, Growth: ${summary.growthRateMBPerHour.toFixed(2)}MB/h`) + } + } + + /** + * Take a memory usage sample + */ + private takeSample(): void { + const usage = process.memoryUsage() + + const snapshot: MemorySnapshot = { + timestamp: Date.now(), + heapUsedMB: usage.heapUsed / (1024 * 1024), + heapTotalMB: usage.heapTotal / (1024 * 1024), + externalMB: usage.external / (1024 * 1024), + rssMB: usage.rss / (1024 * 1024) + } + + this.samples.push(snapshot) + + // Keep only recent samples + if (this.samples.length > this.config.maxSamples) { + this.samples.shift() + } + } + + /** + * Analyze memory trends and emit warnings if needed + */ + private analyzeMemory(): void { + if (this.samples.length < 2) { + return + } + + const latest = this.samples[this.samples.length - 1] + if (!latest) { + return + } + + // Check absolute thresholds + if (latest.heapUsedMB >= this.config.criticalThresholdMB && !this.criticalEmitted) { + this.criticalEmitted = true + log('main', 'MEMORY', `CRITICAL: Heap usage (${latest.heapUsedMB.toFixed(1)}MB) exceeds critical threshold (${this.config.criticalThresholdMB}MB)`, 'error') + this.logDetailedUsage() + } else if (latest.heapUsedMB >= this.config.warningThresholdMB && !this.warningEmitted) { + this.warningEmitted = true + log('main', 'MEMORY', `WARNING: Heap usage (${latest.heapUsedMB.toFixed(1)}MB) exceeds warning threshold (${this.config.warningThresholdMB}MB)`, 'warn') + } + + // Reset warning flags if memory drops below thresholds (allows re-warning) + if (latest.heapUsedMB < this.config.warningThresholdMB * 0.8) { + this.warningEmitted = false + } + if (latest.heapUsedMB < this.config.criticalThresholdMB * 0.8) { + this.criticalEmitted = false + } + + // Check for memory leak pattern (sustained growth) + const growthRate = this.calculateGrowthRate() + if (growthRate > this.config.leakRateMBPerHour && this.samples.length >= 10) { + log('main', 'MEMORY', `Potential memory leak detected: ${growthRate.toFixed(2)}MB/hour growth rate`, 'warn') + } + } + + /** + * Calculate memory growth rate in MB/hour using linear regression + */ + private calculateGrowthRate(): number { + if (this.samples.length < 5) { + return 0 + } + + // Simple linear regression on recent samples + const recentSamples = this.samples.slice(-Math.min(30, this.samples.length)) + const n = recentSamples.length + + if (n === 0) { + return 0 + } + + const firstSample = recentSamples[0] + if (!firstSample) { + return 0 + } + + let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0 + const firstTimestamp = firstSample.timestamp + + for (let i = 0; i < n; i++) { + const sample = recentSamples[i] + if (!sample) continue + const x = (sample.timestamp - firstTimestamp) / 3600000 // Hours + const y = sample.heapUsedMB + sumX += x + sumY += y + sumXY += x * y + sumX2 += x * x + } + + const denominator = n * sumX2 - sumX * sumX + if (denominator === 0) { + return 0 + } + + // Slope = growth rate in MB/hour + return (n * sumXY - sumX * sumY) / denominator + } + + /** + * Log detailed memory usage breakdown + */ + private logDetailedUsage(): void { + const usage = process.memoryUsage() + log('main', 'MEMORY', `Detailed: Heap ${(usage.heapUsed / 1024 / 1024).toFixed(1)}/${(usage.heapTotal / 1024 / 1024).toFixed(1)}MB, RSS ${(usage.rss / 1024 / 1024).toFixed(1)}MB, External ${(usage.external / 1024 / 1024).toFixed(1)}MB`) + } + + /** + * Get current memory usage summary + */ + getSummary(): { currentHeapMB: number; peakHeapMB: number; avgHeapMB: number; growthRateMBPerHour: number; uptimeHours: number } { + const lastSample = this.samples.length > 0 ? this.samples[this.samples.length - 1] : undefined + const current = lastSample?.heapUsedMB ?? 0 + const peak = this.samples.reduce((max, s) => Math.max(max, s.heapUsedMB), 0) + const avg = this.samples.length > 0 ? this.samples.reduce((sum, s) => sum + s.heapUsedMB, 0) / this.samples.length : 0 + const growthRate = this.calculateGrowthRate() + const uptimeHours = (Date.now() - this.startTime) / 3600000 + + return { + currentHeapMB: current, + peakHeapMB: peak, + avgHeapMB: avg, + growthRateMBPerHour: growthRate, + uptimeHours + } + } + + /** + * Force garbage collection if available (requires --expose-gc flag) + */ + forceGC(): boolean { + if (typeof global.gc === 'function') { + global.gc() + log('main', 'MEMORY', 'Forced garbage collection executed') + return true + } + return false + } + + /** + * Get instant memory snapshot without modifying samples + */ + static getInstantUsage(): { heapUsedMB: number; heapTotalMB: number; rssMB: number } { + const usage = process.memoryUsage() + return { + heapUsedMB: usage.heapUsed / (1024 * 1024), + heapTotalMB: usage.heapTotal / (1024 * 1024), + rssMB: usage.rss / (1024 * 1024) + } + } +} + +// Singleton instance for global access +let globalMonitor: MemoryMonitor | null = null + +/** + * Get or create the global memory monitor instance + */ +export function getMemoryMonitor(config?: MemoryConfig): MemoryMonitor { + if (!globalMonitor) { + globalMonitor = new MemoryMonitor(config) + } + return globalMonitor +} + +/** + * Stop and release the global memory monitor + */ +export function stopMemoryMonitor(): void { + if (globalMonitor) { + globalMonitor.stop() + globalMonitor = null + } +} diff --git a/src/util/core/Utils.ts b/src/util/core/Utils.ts index db93e3f..dd4959f 100644 --- a/src/util/core/Utils.ts +++ b/src/util/core/Utils.ts @@ -241,7 +241,7 @@ export function normalizeRecoveryEmail(recoveryEmail: unknown): string | undefin export function replaceUntilStable( input: string, pattern: RegExp, - replacement: string | ((substring: string, ...args: any[]) => string), + replacement: string | ((substring: string, ...args: string[]) => string), maxPasses: number = 1000 ): string { if (!(pattern instanceof RegExp)) { @@ -254,7 +254,11 @@ export function replaceUntilStable( let previous = input for (let i = 0; i < maxPasses; i++) { - const next = previous.replace(globalPattern, replacement as any) + // Type assertion needed for union type compatibility with String.prototype.replace + const next = previous.replace( + globalPattern, + replacement as (substring: string, ...args: string[]) => string + ) if (next === previous) return next previous = next } diff --git a/src/util/state/ActivityStatsTracker.ts b/src/util/state/ActivityStatsTracker.ts new file mode 100644 index 0000000..0b1fefe --- /dev/null +++ b/src/util/state/ActivityStatsTracker.ts @@ -0,0 +1,262 @@ +/** + * Activity Statistics Tracker + * Collects and reports statistics on activity success/failure rates + * Useful for identifying problematic activities and improving bot reliability + */ + +import { log } from '../notifications/Logger' + +interface ActivityStat { + attempts: number + successes: number + failures: number + totalDurationMs: number + lastError?: string + lastAttemptTime: number +} + +interface ActivityStatsConfig { + /** Whether to log periodic summaries (default: true) */ + logSummaries?: boolean + /** Minimum failure rate (0-1) to trigger warnings (default: 0.5) */ + warningFailureRate?: number +} + +const DEFAULT_CONFIG: Required = { + logSummaries: true, + warningFailureRate: 0.5 +} + +export class ActivityStatsTracker { + private stats: Map = new Map() + private config: Required + private sessionStartTime: number = Date.now() + + constructor(config?: ActivityStatsConfig) { + this.config = { ...DEFAULT_CONFIG, ...config } + } + + /** + * Record the start of an activity attempt + * @param activityType Activity type identifier (e.g., 'SEARCH', 'QUIZ', 'POLL') + * @returns Start timestamp for duration calculation + */ + startActivity(activityType: string): number { + const normalizedType = activityType.toUpperCase() + const startTime = Date.now() + + let stat = this.stats.get(normalizedType) + if (!stat) { + stat = { + attempts: 0, + successes: 0, + failures: 0, + totalDurationMs: 0, + lastAttemptTime: startTime + } + this.stats.set(normalizedType, stat) + } + + stat.attempts++ + stat.lastAttemptTime = startTime + + return startTime + } + + /** + * Record a successful activity completion + * @param activityType Activity type identifier + * @param startTime Start timestamp from startActivity() + */ + recordSuccess(activityType: string, startTime: number): void { + const normalizedType = activityType.toUpperCase() + const duration = Date.now() - startTime + + const stat = this.stats.get(normalizedType) + if (stat) { + stat.successes++ + stat.totalDurationMs += duration + } + } + + /** + * Record a failed activity attempt + * @param activityType Activity type identifier + * @param startTime Start timestamp from startActivity() + * @param error Error that caused the failure + */ + recordFailure(activityType: string, startTime: number, error?: Error | string): void { + const normalizedType = activityType.toUpperCase() + const duration = Date.now() - startTime + + const stat = this.stats.get(normalizedType) + if (stat) { + stat.failures++ + stat.totalDurationMs += duration + if (error) { + stat.lastError = error instanceof Error ? error.message : String(error) + } + } + } + + /** + * Get statistics for a specific activity type + */ + getActivityStats(activityType: string): ActivityStat | undefined { + return this.stats.get(activityType.toUpperCase()) + } + + /** + * Get failure rate for a specific activity type (0-1) + */ + getFailureRate(activityType: string): number { + const stat = this.stats.get(activityType.toUpperCase()) + if (!stat || stat.attempts === 0) { + return 0 + } + return stat.failures / stat.attempts + } + + /** + * Get average duration for a specific activity type in milliseconds + */ + getAverageDuration(activityType: string): number { + const stat = this.stats.get(activityType.toUpperCase()) + if (!stat || stat.attempts === 0) { + return 0 + } + return stat.totalDurationMs / stat.attempts + } + + /** + * Get all activity types that exceed the warning failure rate + */ + getProblematicActivities(): Array<{ type: string; failureRate: number; attempts: number; lastError?: string }> { + const problematic: Array<{ type: string; failureRate: number; attempts: number; lastError?: string }> = [] + + for (const [type, stat] of this.stats) { + const failureRate = stat.attempts > 0 ? stat.failures / stat.attempts : 0 + if (failureRate >= this.config.warningFailureRate && stat.attempts >= 2) { + problematic.push({ + type, + failureRate, + attempts: stat.attempts, + lastError: stat.lastError + }) + } + } + + return problematic.sort((a, b) => b.failureRate - a.failureRate) + } + + /** + * Get comprehensive summary of all activity statistics + */ + getSummary(): { + totalAttempts: number + totalSuccesses: number + totalFailures: number + overallSuccessRate: number + sessionDurationHours: number + byActivity: Array<{ + type: string + attempts: number + successes: number + failures: number + successRate: number + avgDurationMs: number + }> + } { + let totalAttempts = 0 + let totalSuccesses = 0 + let totalFailures = 0 + const byActivity: Array<{ + type: string + attempts: number + successes: number + failures: number + successRate: number + avgDurationMs: number + }> = [] + + for (const [type, stat] of this.stats) { + totalAttempts += stat.attempts + totalSuccesses += stat.successes + totalFailures += stat.failures + + byActivity.push({ + type, + attempts: stat.attempts, + successes: stat.successes, + failures: stat.failures, + successRate: stat.attempts > 0 ? stat.successes / stat.attempts : 0, + avgDurationMs: stat.attempts > 0 ? stat.totalDurationMs / stat.attempts : 0 + }) + } + + return { + totalAttempts, + totalSuccesses, + totalFailures, + overallSuccessRate: totalAttempts > 0 ? totalSuccesses / totalAttempts : 0, + sessionDurationHours: (Date.now() - this.sessionStartTime) / 3600000, + byActivity: byActivity.sort((a, b) => b.attempts - a.attempts) + } + } + + /** + * Log summary to console/webhook + */ + logSummary(): void { + if (!this.config.logSummaries) { + return + } + + const summary = this.getSummary() + + if (summary.totalAttempts === 0) { + return + } + + log('main', 'ACTIVITY-STATS', `Session summary: ${summary.totalSuccesses}/${summary.totalAttempts} activities succeeded (${(summary.overallSuccessRate * 100).toFixed(1)}%)`) + + // Log problematic activities + const problematic = this.getProblematicActivities() + if (problematic.length > 0) { + for (const activity of problematic) { + log('main', 'ACTIVITY-STATS', `High failure rate: ${activity.type} (${(activity.failureRate * 100).toFixed(0)}% failed, ${activity.attempts} attempts)${activity.lastError ? ` - Last error: ${activity.lastError.substring(0, 80)}` : ''}`, 'warn') + } + } + } + + /** + * Reset all statistics (call between bot runs) + */ + reset(): void { + this.stats.clear() + this.sessionStartTime = Date.now() + } +} + +// Singleton instance for global access +let globalTracker: ActivityStatsTracker | null = null + +/** + * Get or create the global activity stats tracker instance + */ +export function getActivityStatsTracker(config?: ActivityStatsConfig): ActivityStatsTracker { + if (!globalTracker) { + globalTracker = new ActivityStatsTracker(config) + } + return globalTracker +} + +/** + * Reset and release the global tracker + */ +export function resetActivityStatsTracker(): void { + if (globalTracker) { + globalTracker.logSummary() + globalTracker.reset() + } +}