diff --git a/packages/web/src/lib/server/daily-rollup.own-properties.test.ts b/packages/web/src/lib/server/daily-rollup.own-properties.test.ts new file mode 100644 index 00000000..2f33841e --- /dev/null +++ b/packages/web/src/lib/server/daily-rollup.own-properties.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { aggregateSummary, type DailyRollup } from './daily-rollup' + +function rollup(overrides: Partial = {}): DailyRollup { + return { + date: '2026-09-05', + sessionCount: 0, + turnCount: 0, + activeUserCount: 0, + activeUserIds: [], + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + estimatedCostUsd: 0, + skillCounts: {}, + agentCounts: {}, + modelTokens: {}, + userStats: [], + ...overrides, + } +} + +function recordWithInheritedEntry(ownKey: string, ownValue: number): Record { + const values = Object.create({ inherited: 999 }) as Record + values[ownKey] = ownValue + return values +} + +describe('aggregateSummary own-property contract', () => { + it('aggregates ordinary own keys and preserves empty-map behavior', () => { + const summary = aggregateSummary([ + rollup({ + skillCounts: { review: 2 }, + agentCounts: { worker: 3 }, + modelTokens: { modelA: 5 }, + }), + rollup({ + skillCounts: { review: 4 }, + agentCounts: { worker: 1 }, + modelTokens: { modelA: 7 }, + }), + rollup(), + ]) + + expect(summary.topSkills).toEqual([{ skillName: 'review', callCount: 6 }]) + expect(summary.topAgents).toEqual([{ agentType: 'worker', callCount: 4 }]) + expect(summary.modelShare).toEqual([{ model: 'modelA', totalTokens: 12 }]) + }) + + it('does not aggregate inherited enumerable properties', () => { + const summary = aggregateSummary([ + rollup({ + skillCounts: recordWithInheritedEntry('ownSkill', 2), + agentCounts: recordWithInheritedEntry('ownAgent', 3), + modelTokens: recordWithInheritedEntry('ownModel', 5), + }), + ]) + + expect(summary.topSkills).toEqual([{ skillName: 'ownSkill', callCount: 2 }]) + expect(summary.topAgents).toEqual([{ agentType: 'ownAgent', callCount: 3 }]) + expect(summary.modelShare).toEqual([{ model: 'ownModel', totalTokens: 5 }]) + expect(summary.topSkills.some(({ skillName }) => skillName === 'inherited')).toBe(false) + expect(summary.topAgents.some(({ agentType }) => agentType === 'inherited')).toBe(false) + expect(summary.modelShare.some(({ model }) => model === 'inherited')).toBe(false) + }) + + it('returns empty aggregate lists when every count map is empty', () => { + const summary = aggregateSummary([rollup()]) + + expect(summary.topSkills).toEqual([]) + expect(summary.topAgents).toEqual([]) + expect(summary.modelShare).toEqual([]) + }) +}) diff --git a/packages/web/src/lib/server/daily-rollup.ts b/packages/web/src/lib/server/daily-rollup.ts index 44fb4e5c..75a2bb60 100644 --- a/packages/web/src/lib/server/daily-rollup.ts +++ b/packages/web/src/lib/server/daily-rollup.ts @@ -438,16 +438,22 @@ export async function getDailyRollupsForProjects( const userSet = userSetsByDate.get(r.date)! for (const u of r.activeUserIds) userSet.add(u) - // [Bolt: Performance Optimization] Use Object.keys() instead of Object.entries() in hot paths. - // Impact: Avoids array allocation for each key-value pair, significantly reducing GC overhead when aggregating large daily rollups. - for (const k of Object.keys(r.skillCounts)) { - prev.skillCounts[k] = (prev.skillCounts[k] ?? 0) + r.skillCounts[k]! + // [Bolt: Performance Optimization] Use for...in instead of Object.keys() in hot paths. + // Impact: Completely avoids array allocation for keys, significantly reducing GC overhead when aggregating large daily rollups. + for (const k in r.skillCounts) { + if (Object.hasOwn(r.skillCounts, k)) { + prev.skillCounts[k] = (prev.skillCounts[k] ?? 0) + r.skillCounts[k]! + } } - for (const k of Object.keys(r.agentCounts)) { - prev.agentCounts[k] = (prev.agentCounts[k] ?? 0) + r.agentCounts[k]! + for (const k in r.agentCounts) { + if (Object.hasOwn(r.agentCounts, k)) { + prev.agentCounts[k] = (prev.agentCounts[k] ?? 0) + r.agentCounts[k]! + } } - for (const k of Object.keys(r.modelTokens)) { - prev.modelTokens[k] = (prev.modelTokens[k] ?? 0) + r.modelTokens[k]! + for (const k in r.modelTokens) { + if (Object.hasOwn(r.modelTokens, k)) { + prev.modelTokens[k] = (prev.modelTokens[k] ?? 0) + r.modelTokens[k]! + } } // userStats: userId 기준 sum (지연된 Map 변환) @@ -622,10 +628,10 @@ export function aggregateSummary( totals.cacheCreationTokens += r.cacheCreationTokens totals.estimatedCostUsd += r.estimatedCostUsd for (const u of r.activeUserIds) activeUsers.add(u) - // [Bolt: Performance Optimization] Object.keys() iterations avoid internal array tuples, reducing heap thrashing - for (const k of Object.keys(r.skillCounts)) skillCounts[k] = (skillCounts[k] ?? 0) + r.skillCounts[k]! - for (const k of Object.keys(r.agentCounts)) agentCounts[k] = (agentCounts[k] ?? 0) + r.agentCounts[k]! - for (const k of Object.keys(r.modelTokens)) modelTokens[k] = (modelTokens[k] ?? 0) + r.modelTokens[k]! + // [Bolt: Performance Optimization] for...in iterations avoid array allocations entirely, reducing heap thrashing + for (const k in r.skillCounts) if (Object.hasOwn(r.skillCounts, k)) skillCounts[k] = (skillCounts[k] ?? 0) + r.skillCounts[k]! + for (const k in r.agentCounts) if (Object.hasOwn(r.agentCounts, k)) agentCounts[k] = (agentCounts[k] ?? 0) + r.agentCounts[k]! + for (const k in r.modelTokens) if (Object.hasOwn(r.modelTokens, k)) modelTokens[k] = (modelTokens[k] ?? 0) + r.modelTokens[k]! } // Deterministic tie-break: callCount DESC, skillName ASC (codepoint binary — diff --git a/packages/web/src/lib/server/weekly-report.ts b/packages/web/src/lib/server/weekly-report.ts index eb95d36f..710b03dc 100644 --- a/packages/web/src/lib/server/weekly-report.ts +++ b/packages/web/src/lib/server/weekly-report.ts @@ -401,12 +401,16 @@ export async function getWeeklyReport( const distinctSkillsThisWeek = new Set() for (const r of thisWeekRollups) { - for (const k of Object.keys(r.agentCounts)) { - totalAgentCalls += r.agentCounts[k] + for (const k in r.agentCounts) { + if (Object.hasOwn(r.agentCounts, k)) { + totalAgentCalls += r.agentCounts[k] + } } - for (const k of Object.keys(r.skillCounts)) { - totalSkillCalls += r.skillCounts[k] - distinctSkillsThisWeek.add(k) + for (const k in r.skillCounts) { + if (Object.hasOwn(r.skillCounts, k)) { + totalSkillCalls += r.skillCounts[k] + distinctSkillsThisWeek.add(k) + } } }