/**
* MetricsReducer — subscribes to scan-stream events and records counts + timing
* derived purely from event timestamps at boundaries. No setInterval, no sampling.
*
* Attach alongside TreeReducer to profile a scan without touching the scan code.
*/
export class MetricsReducer {
constructor () {
this.scanStart = null
this.scanEnd = null
this.moduleCount = 0
this.fileCount = 0
this.parseCount = 0
this.parseErrors = 0
this.importEdges = 0
this.implicitEdges = 0
this.moduleTimings = [] // [{ name, startedAt, endedAt, duration }]
this._currentModule = null
this.bytesRead = 0
this.largestFile = { path: null, size: 0 }
this.filesByExt = {} // ext -> count
this.parseTimeTotal = 0 // sum of time between file-discovered and file-parsed for the SAME file
this._pendingParse = new Map() // relPath -> discoveredAt
this.parseDurations = [] // per-file parse durations (ms) for percentiles
}
attach (emitter) {
emitter.on('scan-start', () => { this.scanStart = Date.now() })
emitter.on('module-start', e => {
this.moduleCount++
this._currentModule = { name: e.name, startedAt: Date.now() }
})
emitter.on('file-discovered', e => {
this.fileCount++
this.bytesRead += e.size
if (e.size > this.largestFile.size) {
this.largestFile = { path: e.relPath, size: e.size }
}
const dotIdx = e.relInPkg.lastIndexOf('.')
const ext = dotIdx > 0 ? e.relInPkg.slice(dotIdx) : '(none)'
this.filesByExt[ext] = (this.filesByExt[ext] || 0) + 1
this._pendingParse.set(e.relPath, Date.now())
})
emitter.on('file-parsed', e => {
this.parseCount++
if (e.parsed && e.parsed.error) this.parseErrors++
const startedAt = this._pendingParse.get(e.relPath)
if (startedAt) {
const dur = Date.now() - startedAt
this.parseTimeTotal += dur
this.parseDurations.push(dur)
this._pendingParse.delete(e.relPath)
}
})
emitter.on('import-edge', e => {
this.importEdges++
if (e.implicit) this.implicitEdges++
})
emitter.on('module-end', () => {
if (this._currentModule) {
const endedAt = Date.now()
this.moduleTimings.push({
name: this._currentModule.name,
startedAt: this._currentModule.startedAt,
endedAt,
duration: endedAt - this._currentModule.startedAt
})
this._currentModule = null
}
})
emitter.on('scan-end', () => { this.scanEnd = Date.now() })
return this
}
report () {
const duration = this.scanEnd - this.scanStart
const slowestModules = [...this.moduleTimings]
.sort((a, b) => b.duration - a.duration)
.slice(0, 5)
const sortedParses = [...this.parseDurations].sort((a, b) => a - b)
const p50 = sortedParses[Math.floor(sortedParses.length * 0.5)] || 0
const p95 = sortedParses[Math.floor(sortedParses.length * 0.95)] || 0
const p99 = sortedParses[Math.floor(sortedParses.length * 0.99)] || 0
const topExts = Object.entries(this.filesByExt)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
return {
totalDuration: duration,
moduleCount: this.moduleCount,
fileCount: this.fileCount,
parseCount: this.parseCount,
parseErrors: this.parseErrors,
importEdges: this.importEdges,
implicitEdges: this.implicitEdges,
bytesRead: this.bytesRead,
largestFile: this.largestFile,
parseTimeP50: p50,
parseTimeP95: p95,
parseTimeP99: p99,
parseTimeTotal: this.parseTimeTotal,
throughputFilesPerSec: (this.fileCount * 1000 / duration).toFixed(1),
throughputMBPerSec: ((this.bytesRead / 1024 / 1024) * 1000 / duration).toFixed(1),
slowestModules,
topExtensions: topExts
}
}
}