Skip to content

Commit 644ace8

Browse files
authored
feat: track API profile per session for per-profile stats (#32) (#38)
- Add Profile field to SessionRecord, populated with config.Default at scan time - Add ByProfile map to DailyStat for per-profile cost aggregation - Add ProfileCost struct and TopProfiles to Summary - Add ProfileBreakdown() aggregator function - Update Aggregate() to populate ByProfile data - Add 'profile' view to TUI Stats tab f-key cycle (all→project→model→profile→all) - Add aggregateByProfile() and rendering in stats_view.go
1 parent fc86427 commit 644ace8

4 files changed

Lines changed: 71 additions & 3 deletions

File tree

internal/stats/aggregator.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ func Aggregate(records []SessionRecord, from, to time.Time) []DailyStat {
2626
Date: date,
2727
ByProject: make(map[string]float64),
2828
ByModel: make(map[string]float64),
29+
ByProfile: make(map[string]float64),
2930
}
3031
dailyMap[date] = ds
3132
}
@@ -36,6 +37,11 @@ func Aggregate(records []SessionRecord, from, to time.Time) []DailyStat {
3637
ds.OutputTokens += r.OutputTokens
3738
ds.ByProject[r.Project] += r.CostUSD
3839
ds.ByModel[r.Model] += r.CostUSD
40+
profile := r.Profile
41+
if profile == "" {
42+
profile = "unknown"
43+
}
44+
ds.ByProfile[profile] += r.CostUSD
3945
}
4046

4147
// Convert map to sorted slice
@@ -154,6 +160,28 @@ func ModelBreakdown(stats []DailyStat) []ModelCost {
154160
return result
155161
}
156162

163+
// ProfileBreakdown aggregates costs by API profile across all daily stats.
164+
// Returns a slice of ProfileCost sorted by cost descending.
165+
func ProfileBreakdown(stats []DailyStat) []ProfileCost {
166+
totals := make(map[string]float64)
167+
for _, s := range stats {
168+
for profile, cost := range s.ByProfile {
169+
totals[profile] += cost
170+
}
171+
}
172+
173+
result := make([]ProfileCost, 0, len(totals))
174+
for profile, cost := range totals {
175+
result = append(result, ProfileCost{Profile: profile, Cost: cost})
176+
}
177+
178+
sort.Slice(result, func(i, j int) bool {
179+
return result[i].Cost > result[j].Cost
180+
})
181+
182+
return result
183+
}
184+
157185
// GenerateSummary creates a comprehensive summary from session records.
158186
func GenerateSummary(records []SessionRecord, from, to time.Time) Summary {
159187
dailyStats := Aggregate(records, from, to)
@@ -183,6 +211,7 @@ func GenerateSummary(records []SessionRecord, from, to time.Time) Summary {
183211
CacheRead: cacheRead,
184212
TopProjects: ProjectBreakdown(dailyStats),
185213
TopModels: ModelBreakdown(dailyStats),
214+
TopProfiles: ProfileBreakdown(dailyStats),
186215
DailyBreakdown: dailyStats,
187216
}
188217
}

internal/stats/scanner.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ func ScanSessions(opts ScanOptions) ([]SessionRecord, error) {
9191
projects = make(map[string]config.ProjectEntry)
9292
}
9393

94+
// Determine current default profile name
95+
currentProfile := "unknown"
96+
if cfg, err := config.LoadConfig(); err == nil && cfg.Default != "" {
97+
currentProfile = cfg.Default
98+
}
99+
94100
var records []SessionRecord
95101

96102
for _, entry := range entries {
@@ -126,7 +132,7 @@ func ScanSessions(opts ScanOptions) ([]SessionRecord, error) {
126132
sessionID := strings.TrimSuffix(sf.Name(), ".jsonl")
127133
filePath := filepath.Join(dirPath, sf.Name())
128134

129-
record, err := parseSessionFile(filePath, sessionID, projectAlias, projectPath)
135+
record, err := parseSessionFile(filePath, sessionID, projectAlias, projectPath, currentProfile)
130136
if err != nil {
131137
continue // skip unparseable files
132138
}
@@ -140,7 +146,7 @@ func ScanSessions(opts ScanOptions) ([]SessionRecord, error) {
140146
}
141147

142148
// parseSessionFile reads a single JSONL session file and extracts a SessionRecord.
143-
func parseSessionFile(path, sessionID, project, projectPath string) (*SessionRecord, error) {
149+
func parseSessionFile(path, sessionID, project, projectPath, profile string) (*SessionRecord, error) {
144150
f, err := os.Open(path)
145151
if err != nil {
146152
return nil, fmt.Errorf("open session file: %w", err)
@@ -151,6 +157,7 @@ func parseSessionFile(path, sessionID, project, projectPath string) (*SessionRec
151157
SessionID: sessionID,
152158
Project: project,
153159
ProjectPath: projectPath,
160+
Profile: profile,
154161
}
155162

156163
var (

internal/stats/types.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ type SessionRecord struct {
77
SessionID string `json:"sessionId"`
88
Project string `json:"project"` // codes project alias (e.g. "codes"), falls back to path
99
ProjectPath string `json:"projectPath"` // full filesystem path
10+
Profile string `json:"profile"` // API profile name (config.Default at scan time), falls back to "unknown"
1011
Model string `json:"model"`
1112
StartTime time.Time `json:"startTime"`
1213
EndTime time.Time `json:"endTime"`
@@ -28,6 +29,7 @@ type DailyStat struct {
2829
OutputTokens int64 `json:"outputTokens"`
2930
ByProject map[string]float64 `json:"byProject"` // project alias -> cost
3031
ByModel map[string]float64 `json:"byModel"` // model name -> cost
32+
ByProfile map[string]float64 `json:"byProfile"` // API profile name -> cost
3133
}
3234

3335
// StatsCache is the on-disk cache of all scanned session data.
@@ -55,6 +57,7 @@ type Summary struct {
5557
CacheRead int64 `json:"cacheReadTokens"`
5658
TopProjects []ProjectCost `json:"topProjects"`
5759
TopModels []ModelCost `json:"topModels"`
60+
TopProfiles []ProfileCost `json:"topProfiles"`
5861
DailyBreakdown []DailyStat `json:"dailyBreakdown"`
5962
}
6063

@@ -69,3 +72,9 @@ type ModelCost struct {
6972
Model string `json:"model"`
7073
Cost float64 `json:"cost"`
7174
}
75+
76+
// ProfileCost represents cost aggregation for a single API profile.
77+
type ProfileCost struct {
78+
Profile string `json:"profile"`
79+
Cost float64 `json:"cost"`
80+
}

internal/tui/stats_view.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ func (m Model) updateStats(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
8888
case "project":
8989
m.statsBreakdown = "model"
9090
case "model":
91+
m.statsBreakdown = "profile"
92+
case "profile":
9193
m.statsBreakdown = "both"
9294
}
9395
return m, nil
@@ -186,6 +188,17 @@ func renderStatsView(daily []stats.DailyStat, records []stats.SessionRecord, tim
186188
}
187189
}
188190

191+
// By Profile breakdown
192+
if breakdown == "both" || breakdown == "profile" {
193+
profileCosts := aggregateByProfile(daily)
194+
if len(profileCosts) > 0 {
195+
b.WriteString(statsHeaderStyle.Render(" By Profile:"))
196+
b.WriteString("\n")
197+
renderBreakdown(&b, profileCosts, totalCost, barWidth, 8)
198+
b.WriteString("\n")
199+
}
200+
}
201+
189202
// Daily trend chart
190203
if len(daily) > 1 {
191204
b.WriteString(statsHeaderStyle.Render(" Daily Trend:"))
@@ -195,7 +208,7 @@ func renderStatsView(daily []stats.DailyStat, records []stats.SessionRecord, tim
195208

196209
// Help footer
197210
b.WriteString("\n")
198-
breakdownLabel := map[string]string{"both": "all", "project": "project", "model": "model"}[breakdown]
211+
breakdownLabel := map[string]string{"both": "all", "project": "project", "model": "model", "profile": "profile"}[breakdown]
199212
help := fmt.Sprintf(" w:week m:month a:all f:group(%s) r:refresh", breakdownLabel)
200213
b.WriteString(statsDimStyle.Render(help))
201214

@@ -228,6 +241,16 @@ func aggregateByModel(daily []stats.DailyStat) []costEntry {
228241
return sortedEntries(totals)
229242
}
230243

244+
func aggregateByProfile(daily []stats.DailyStat) []costEntry {
245+
totals := make(map[string]float64)
246+
for _, d := range daily {
247+
for profile, cost := range d.ByProfile {
248+
totals[profile] += cost
249+
}
250+
}
251+
return sortedEntries(totals)
252+
}
253+
231254
func sortedEntries(m map[string]float64) []costEntry {
232255
entries := make([]costEntry, 0, len(m))
233256
for name, cost := range m {

0 commit comments

Comments
 (0)