mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-09 17:06:15 +00:00
feat: add memory and activity statistics tracking for improved bot performance
This commit is contained in:
@@ -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 |
BIN
assets/logo.png
BIN
assets/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 688 KiB After Width: | Height: | Size: 973 KiB |
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/index.ts
13
src/index.ts
@@ -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 {
|
||||||
|
|||||||
268
src/util/core/MemoryMonitor.ts
Normal file
268
src/util/core/MemoryMonitor.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
262
src/util/state/ActivityStatsTracker.ts
Normal file
262
src/util/state/ActivityStatsTracker.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user