mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-09 00:56:16 +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 { 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<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)
|
||||
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<never>((_, 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<never>((_, 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)
|
||||
}
|
||||
|
||||
13
src/index.ts
13
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<void> {
|
||||
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<void> {
|
||||
// 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 {
|
||||
|
||||
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(
|
||||
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
|
||||
}
|
||||
|
||||
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