OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / reducers / metrics-reducer.js

111 lines · javascript ref:
/**
 * 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
    }
  }
}