feat: add memory and activity statistics tracking for improved bot performance

This commit is contained in:
2025-12-16 21:16:34 +01:00
parent 5f17af5b4d
commit 3972767c81
9 changed files with 622 additions and 102 deletions

View File

@@ -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 | `<img src="assets/logo.png" width="200"/>` |
| Documentation pages | 120-150px | `<img src="../assets/logo.png" width="120"/>` |
| GitHub social preview | 1200x630px | Resize as needed |
| Favicon | 32x32px or 64x64px | Convert to ICO format |
### Usage Examples
**In README.md (root):**
```markdown
<div align="center">
<img src="assets/logo.png" alt="Microsoft Rewards Script Logo" width="200"/>
</div>
```
**In docs/*.md files:**
```markdown
<div align="center">
<img src="../assets/logo.png" alt="Microsoft Rewards Script Logo" width="120"/>
</div>
```
**In HTML:**
```html
<img src="assets/logo.png" alt="Microsoft Rewards Script Logo" style="width: 200px; display: block; margin: 0 auto;">
```
## 🎨 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
---
<div align="center">
**[← Back to README](../README.md)**
</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

After

Width:  |  Height:  |  Size: 973 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 688 KiB

After

Width:  |  Height:  |  Size: 973 KiB

View File

@@ -13,6 +13,7 @@ import type { Config } from '../interface/Config'
import { ConclusionWebhook } from '../util/notifications/ConclusionWebhook' import { ConclusionWebhook } from '../util/notifications/ConclusionWebhook'
import { log } from '../util/notifications/Logger' import { log } from '../util/notifications/Logger'
import { Ntfy } from '../util/notifications/Ntfy' import { Ntfy } from '../util/notifications/Ntfy'
import { getActivityStatsTracker, resetActivityStatsTracker } from '../util/state/ActivityStatsTracker'
import { JobState } from '../util/state/JobState' import { JobState } from '../util/state/JobState'
export interface AccountResult { export interface AccountResult {
@@ -258,12 +259,52 @@ export class SummaryReporter {
log('main', 'SUMMARY', '═'.repeat(80)) log('main', 'SUMMARY', '═'.repeat(80))
// Log activity statistics
this.logActivityStats()
// Send notifications // Send notifications
await Promise.all([ await Promise.all([
this.sendWebhookSummary(summary), this.sendWebhookSummary(summary),
this.sendPushNotification(summary), this.sendPushNotification(summary),
this.updateJobState(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')
}
}
} }
/** /**

View File

@@ -8,6 +8,7 @@ import { waitForElementSmart, waitForNetworkIdle } from '../util/browser/SmartWa
import { Retry } from '../util/core/Retry' import { Retry } from '../util/core/Retry'
import { AdaptiveThrottler } from '../util/notifications/AdaptiveThrottler' import { AdaptiveThrottler } from '../util/notifications/AdaptiveThrottler'
import { logError } from '../util/notifications/Logger' import { logError } from '../util/notifications/Logger'
import { getActivityStatsTracker } from '../util/state/ActivityStatsTracker'
import JobState from '../util/state/JobState' import JobState from '../util/state/JobState'
// Selector patterns (extracted to avoid magic strings) // 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<void> { private async executeActivity(page: Page, activity: PromotionalItem | MorePromotion, selector: string, throttle: AdaptiveThrottler, retry: Retry): Promise<void> {
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) // IMPROVED: Fast-fail for unavailable activities (1s+3s instead of 2s+5s)
const elementResult = await waitForElementSmart(page, selector, { const elementResult = await waitForElementSmart(page, selector, {
@@ -279,6 +284,7 @@ export class Workers {
if (!elementResult.found) { if (!elementResult.found) {
this.bot.log(this.bot.isMobile, 'ACTIVITY', `[SKIP] Activity not available: "${activity.title}" (already completed or not offered today)`) 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 return // Skip this activity gracefully
} }
@@ -294,6 +300,7 @@ export class Workers {
} catch (clickError) { } catch (clickError) {
const errMsg = clickError instanceof Error ? clickError.message : String(clickError) const errMsg = clickError instanceof Error ? clickError.message : String(clickError)
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Failed to click activity: ${errMsg}`, 'error') 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}`) throw new Error(`Activity click failed: ${errMsg}`)
} }
@@ -302,24 +309,31 @@ export class Workers {
// Execute activity with timeout protection using Promise.race // Execute activity with timeout protection using Promise.race
const timeoutMs = this.bot.utils.stringToMs(this.bot.config?.globalTimeout ?? '30s') * 2 const timeoutMs = this.bot.utils.stringToMs(this.bot.config?.globalTimeout ?? '30s') * 2
await retry.run(async () => { try {
const activityPromise = this.bot.activities.run(page, activity) await retry.run(async () => {
const timeoutPromise = new Promise<never>((_, reject) => { const activityPromise = this.bot.activities.run(page, activity)
const timer = setTimeout(() => { const timeoutPromise = new Promise<never>((_, reject) => {
reject(new Error(`Activity execution timeout after ${timeoutMs}ms`)) const timer = setTimeout(() => {
}, timeoutMs) reject(new Error(`Activity execution timeout after ${timeoutMs}ms`))
// Clean up timer if activity completes first }, timeoutMs)
activityPromise.finally(() => clearTimeout(timer)) // Clean up timer if activity completes first
}) activityPromise.finally(() => clearTimeout(timer))
})
try { try {
await Promise.race([activityPromise, timeoutPromise]) await Promise.race([activityPromise, timeoutPromise])
throttle.record(true) throttle.record(true)
} catch (e) { } catch (e) {
throttle.record(false) throttle.record(false)
throw e throw e
} }
}, () => true) }, () => 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) await this.bot.browser.utils.humanizePage(page)
} }

View File

@@ -10,6 +10,7 @@ import { createInterface } from 'readline'
import BrowserFunc from './browser/BrowserFunc' import BrowserFunc from './browser/BrowserFunc'
import BrowserUtil from './browser/BrowserUtil' import BrowserUtil from './browser/BrowserUtil'
import Humanizer from './util/browser/Humanizer' import Humanizer from './util/browser/Humanizer'
import { getMemoryMonitor, stopMemoryMonitor } from './util/core/MemoryMonitor'
import { formatDetailedError, normalizeRecoveryEmail, shortErrorMessage, Util } from './util/core/Utils' import { formatDetailedError, normalizeRecoveryEmail, shortErrorMessage, Util } from './util/core/Utils'
import Axios from './util/network/Axios' import Axios from './util/network/Axios'
import { QueryDiversityEngine } from './util/network/QueryDiversityEngine' import { QueryDiversityEngine } from './util/network/QueryDiversityEngine'
@@ -1059,18 +1060,21 @@ async function main(): Promise<void> {
log('main', 'FATAL', `UncaughtException: ${err.message}${err.stack ? `\nStack: ${err.stack.split('\n').slice(0, 3).join(' | ')}` : ''}`, 'error') log('main', 'FATAL', `UncaughtException: ${err.message}${err.stack ? `\nStack: ${err.stack.split('\n').slice(0, 3).join(' | ')}` : ''}`, 'error')
scheduler?.stop() // Stop scheduler before exit scheduler?.stop() // Stop scheduler before exit
stopWebhookCleanup() stopWebhookCleanup()
stopMemoryMonitor() // Stop memory monitoring before exit
gracefulExit(1) gracefulExit(1)
}) })
process.on('SIGTERM', () => { process.on('SIGTERM', () => {
log('main', 'SHUTDOWN', 'Received SIGTERM, shutting down gracefully...', 'log') log('main', 'SHUTDOWN', 'Received SIGTERM, shutting down gracefully...', 'log')
scheduler?.stop() // Stop scheduler before exit scheduler?.stop() // Stop scheduler before exit
stopWebhookCleanup() stopWebhookCleanup()
stopMemoryMonitor() // Stop memory monitoring before exit
gracefulExit(0) gracefulExit(0)
}) })
process.on('SIGINT', () => { process.on('SIGINT', () => {
log('main', 'SHUTDOWN', 'Received SIGINT (Ctrl+C), shutting down gracefully...', 'log') log('main', 'SHUTDOWN', 'Received SIGINT (Ctrl+C), shutting down gracefully...', 'log')
scheduler?.stop() // Stop scheduler before exit scheduler?.stop() // Stop scheduler before exit
stopWebhookCleanup() stopWebhookCleanup()
stopMemoryMonitor() // Stop memory monitoring before exit
gracefulExit(0) gracefulExit(0)
}) })
} }
@@ -1178,6 +1182,15 @@ async function main(): Promise<void> {
// This gives users instant confirmation of the cron schedule without waiting for long execution // 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') 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 // Initialize and start scheduler first
scheduler = new InternalScheduler(config, async () => { scheduler = new InternalScheduler(config, async () => {
try { try {

View File

@@ -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<MemoryConfig> = {
warningThresholdMB: 500,
criticalThresholdMB: 1024,
leakRateMBPerHour: 50,
samplingIntervalMs: 60000,
maxSamples: 60
}
export class MemoryMonitor {
private config: Required<MemoryConfig>
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
}
}

View File

@@ -241,7 +241,7 @@ export function normalizeRecoveryEmail(recoveryEmail: unknown): string | undefin
export function replaceUntilStable( export function replaceUntilStable(
input: string, input: string,
pattern: RegExp, pattern: RegExp,
replacement: string | ((substring: string, ...args: any[]) => string), replacement: string | ((substring: string, ...args: string[]) => string),
maxPasses: number = 1000 maxPasses: number = 1000
): string { ): string {
if (!(pattern instanceof RegExp)) { if (!(pattern instanceof RegExp)) {
@@ -254,7 +254,11 @@ export function replaceUntilStable(
let previous = input let previous = input
for (let i = 0; i < maxPasses; i++) { 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 if (next === previous) return next
previous = next previous = next
} }

View File

@@ -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<ActivityStatsConfig> = {
logSummaries: true,
warningFailureRate: 0.5
}
export class ActivityStatsTracker {
private stats: Map<string, ActivityStat> = new Map()
private config: Required<ActivityStatsConfig>
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()
}
}