From 75d54943d6197b7ce18829d65b1c668dfb3e8f2a Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Fri, 13 Mar 2026 12:58:36 -0700 Subject: [PATCH 1/6] add usage cmd --- cmd/usage.go | 621 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 621 insertions(+) create mode 100644 cmd/usage.go diff --git a/cmd/usage.go b/cmd/usage.go new file mode 100644 index 0000000..be9a6f9 --- /dev/null +++ b/cmd/usage.go @@ -0,0 +1,621 @@ +package cmd + +import ( + "fmt" + "math" + "sort" + "sync" + "time" + + "github.com/scoutapm/scout/internal/api" + "github.com/scoutapm/scout/internal/output" + "github.com/spf13/cobra" +) + +var ( + showAllApps bool + byDay bool + byApp bool +) + +var usageCmd = &cobra.Command{ + Use: "usage", + Short: "Show transaction usage across all apps", + Run: runUsage, +} + +func init() { + usageCmd.Flags().BoolVar(&showAllApps, "all", false, "Include apps with zero usage") + usageCmd.Flags().BoolVar(&byDay, "by-day", false, "Show daily transaction breakdown") + usageCmd.Flags().BoolVar(&byApp, "by-app", false, "Break down by app (use with --by-day)") + rootCmd.AddCommand(usageCmd) +} + +type appUsage struct { + ID int `json:"id"` + Name string `json:"name"` + Transactions float64 `json:"transactions"` +} + +type dailyUsage struct { + Date string `json:"date"` + Transactions int64 `json:"transactions"` + TopEndpoint string `json:"top_endpoint,omitempty"` +} + +type dailyAppUsage struct { + AppID int `json:"app_id"` + AppName string `json:"app_name"` + Transactions int64 `json:"transactions"` + TopEndpoint string `json:"top_endpoint,omitempty"` +} + +type dailyReport struct { + Date string `json:"date"` + Total int64 `json:"total"` + Apps []dailyAppUsage `json:"apps"` +} + +func runUsage(cmd *cobra.Command, args []string) { + if byDay { + runUsageByDay(cmd, args) + return + } + + client, err := getClient() + if err != nil { + exitError(err.Error()) + } + + from, to, err := resolveTimeframe() + if err != nil { + exitError(err.Error()) + } + + apps, err := client.ListApps() + if err != nil { + handleAPIError(err) + return + } + + chunks := splitTimeframe(from, to) + + var ( + mu sync.Mutex + wg sync.WaitGroup + results []appUsage + ) + + for _, app := range apps { + wg.Add(1) + go func(a api.App) { + defer wg.Done() + txns := fetchAppTransactions(client, a.ID, chunks) + mu.Lock() + results = append(results, appUsage{ + ID: a.ID, + Name: a.Name, + Transactions: txns, + }) + mu.Unlock() + }(app) + } + wg.Wait() + + if !showAllApps { + filtered := results[:0] + for _, r := range results { + if r.Transactions > 0 { + filtered = append(filtered, r) + } + } + results = filtered + } + + sort.Slice(results, func(i, j int) bool { + return results[i].Transactions > results[j].Transactions + }) + + if jsonOutput { + outputJSON(results) + return + } + + printTimeframe(from, to) + + var grandTotal float64 + for _, r := range results { + grandTotal += r.Transactions + } + + total := len(results) + limit, _ := applyLimit(total) + + headers := []string{"Name", "Transactions", "% of Total"} + rows := make([][]string, limit) + for i := 0; i < limit; i++ { + r := results[i] + pct := 0.0 + if grandTotal > 0 { + pct = (r.Transactions / grandTotal) * 100 + } + rows[i] = []string{ + r.Name, + formatTransactions(r.Transactions), + fmt.Sprintf("%.1f%%", pct), + } + } + + fmt.Println(output.RenderTable(headers, rows)) + printTruncated(limit, total) + + if grandTotal > 0 { + fmt.Printf("\nTotal: %s transactions\n", formatTransactions(grandTotal)) + } +} + +func runUsageByDay(cmd *cobra.Command, args []string) { + client, err := getClient() + if err != nil { + exitError(err.Error()) + } + + from, to, err := resolveTimeframe() + if err != nil { + exitError(err.Error()) + } + + if appID > 0 { + runUsageByDaySingleApp(client, appID, from, to) + } else if byApp { + runUsageByDayByApp(client, from, to) + } else { + runUsageByDayAllApps(client, from, to) + } +} + +func runUsageByDayAllApps(client *api.Client, from, to string) { + apps, err := client.ListApps() + if err != nil { + handleAPIError(err) + return + } + + chunks := splitTimeframe(from, to) + + // Fetch all points for all apps in parallel + type appPoints struct { + points []api.MetricPoint + } + var ( + mu sync.Mutex + wg sync.WaitGroup + allPoints []api.MetricPoint + ) + + for _, app := range apps { + wg.Add(1) + go func(a api.App) { + defer wg.Done() + pts := fetchAppPoints(client, a.ID, chunks) + mu.Lock() + allPoints = append(allPoints, pts...) + mu.Unlock() + }(app) + } + wg.Wait() + + days := bucketByDay(allPoints) + + if jsonOutput { + outputJSON(days) + return + } + + printTimeframe(from, to) + + total := len(days) + limit, _ := applyLimit(total) + + headers := []string{"Day", "Transactions"} + rows := make([][]string, limit) + var grandTotal int64 + for i := 0; i < limit; i++ { + d := days[i] + grandTotal += d.Transactions + rows[i] = []string{ + d.Date, + formatTransactions(float64(d.Transactions)), + } + } + + fmt.Println(output.RenderTable(headers, rows)) + printTruncated(limit, total) + + if grandTotal > 0 { + fmt.Printf("\nTotal: %s transactions\n", formatTransactions(float64(grandTotal))) + } +} + +func runUsageByDayByApp(client *api.Client, from, to string) { + apps, err := client.ListApps() + if err != nil { + handleAPIError(err) + return + } + + chunks := splitTimeframe(from, to) + + // Fetch points per app in parallel + type appPointsResult struct { + app api.App + points []api.MetricPoint + } + var ( + mu sync.Mutex + wg sync.WaitGroup + appData []appPointsResult + ) + + for _, app := range apps { + wg.Add(1) + go func(a api.App) { + defer wg.Done() + pts := fetchAppPoints(client, a.ID, chunks) + mu.Lock() + appData = append(appData, appPointsResult{app: a, points: pts}) + mu.Unlock() + }(app) + } + wg.Wait() + + // Bucket each app's points by day + // dayMap[date][appID] = transactions + type appDayInfo struct { + appID int + appName string + txns float64 + } + dayMap := make(map[string][]appDayInfo) + + for _, ad := range appData { + if len(ad.points) < 2 { + continue + } + for i := 1; i < len(ad.points); i++ { + t0, err0 := time.Parse(time.RFC3339, ad.points[i-1].Timestamp) + t1, err1 := time.Parse(time.RFC3339, ad.points[i].Timestamp) + if err0 != nil || err1 != nil { + continue + } + intervalMinutes := t1.Sub(t0).Minutes() + if intervalMinutes <= 0 { + continue + } + day := t0.Format("2006-01-02") + txns := ad.points[i-1].Value * intervalMinutes + // Find or append + found := false + for j := range dayMap[day] { + if dayMap[day][j].appID == ad.app.ID { + dayMap[day][j].txns += txns + found = true + break + } + } + if !found { + dayMap[day] = append(dayMap[day], appDayInfo{ + appID: ad.app.ID, + appName: ad.app.Name, + txns: txns, + }) + } + } + } + + // Build sorted day list + var dates []string + for d := range dayMap { + dates = append(dates, d) + } + sort.Strings(dates) + + // Fetch top endpoints per app per day in parallel + type endpointKey struct { + date string + appID int + } + topEndpoints := make(map[endpointKey]string) + var epMu sync.Mutex + var epWg sync.WaitGroup + + for _, date := range dates { + for _, info := range dayMap[date] { + if info.txns <= 0 { + continue + } + epWg.Add(1) + go func(d string, aID int) { + defer epWg.Done() + dayStart := d + "T00:00:00Z" + dayEnd := d + "T23:59:59Z" + endpoints, err := client.ListEndpoints(aID, dayStart, dayEnd) + if err != nil || len(endpoints) == 0 { + return + } + epMu.Lock() + topEndpoints[endpointKey{date: d, appID: aID}] = endpoints[0].Name + epMu.Unlock() + }(date, info.appID) + } + } + epWg.Wait() + + // Build reports + reports := make([]dailyReport, 0, len(dates)) + for _, date := range dates { + appInfos := dayMap[date] + sort.Slice(appInfos, func(i, j int) bool { + return appInfos[i].txns > appInfos[j].txns + }) + + var dayTotal int64 + appUsages := make([]dailyAppUsage, 0, len(appInfos)) + for _, info := range appInfos { + txns := int64(math.Round(info.txns)) + if txns == 0 && !showAllApps { + continue + } + dayTotal += txns + appUsages = append(appUsages, dailyAppUsage{ + AppID: info.appID, + AppName: info.appName, + Transactions: txns, + TopEndpoint: topEndpoints[endpointKey{date: date, appID: info.appID}], + }) + } + reports = append(reports, dailyReport{ + Date: date, + Total: dayTotal, + Apps: appUsages, + }) + } + + if jsonOutput { + outputJSON(reports) + return + } + + printTimeframe(from, to) + + var grandTotal int64 + for _, report := range reports { + grandTotal += report.Total + } + + for _, report := range reports { + fmt.Printf("%s %s\n", output.HeaderStyle.Render("── "+report.Date+" ──"), + output.DimStyle.Render(fmt.Sprintf("%s transactions", formatTransactions(float64(report.Total))))) + + headers := []string{"App", "Transactions", "% of Day", "% of Total", "Top Endpoint"} + rows := make([][]string, 0, len(report.Apps)) + for _, a := range report.Apps { + pctDay := 0.0 + if report.Total > 0 { + pctDay = (float64(a.Transactions) / float64(report.Total)) * 100 + } + pctTotal := 0.0 + if grandTotal > 0 { + pctTotal = (float64(a.Transactions) / float64(grandTotal)) * 100 + } + rows = append(rows, []string{ + a.AppName, + formatTransactions(float64(a.Transactions)), + fmt.Sprintf("%.1f%%", pctDay), + fmt.Sprintf("%.1f%%", pctTotal), + a.TopEndpoint, + }) + } + fmt.Println(output.RenderTable(headers, rows)) + } + + if grandTotal > 0 { + fmt.Printf("Total: %s transactions\n", formatTransactions(float64(grandTotal))) + } +} + +func runUsageByDaySingleApp(client *api.Client, id int, from, to string) { + chunks := splitTimeframe(from, to) + points := fetchAppPoints(client, id, chunks) + days := bucketByDay(points) + + // Fetch top endpoint for each day in parallel + var wg sync.WaitGroup + for i := range days { + wg.Add(1) + go func(idx int) { + defer wg.Done() + d := days[idx] + dayStart := d.Date + "T00:00:00Z" + dayEnd := d.Date + "T23:59:59Z" + endpoints, err := client.ListEndpoints(id, dayStart, dayEnd) + if err != nil || len(endpoints) == 0 { + return + } + // Endpoints are returned sorted by throughput descending + days[idx].TopEndpoint = endpoints[0].Name + }(i) + } + wg.Wait() + + if jsonOutput { + outputJSON(days) + return + } + + printTimeframe(from, to) + + total := len(days) + limit, _ := applyLimit(total) + + headers := []string{"Day", "Transactions", "Top Endpoint"} + rows := make([][]string, limit) + var grandTotal int64 + for i := 0; i < limit; i++ { + d := days[i] + grandTotal += d.Transactions + rows[i] = []string{ + d.Date, + formatTransactions(float64(d.Transactions)), + d.TopEndpoint, + } + } + + fmt.Println(output.RenderTable(headers, rows)) + printTruncated(limit, total) + + if grandTotal > 0 { + fmt.Printf("\nTotal: %s transactions\n", formatTransactions(float64(grandTotal))) + } +} + +// fetchAppPoints fetches throughput time-series data across all time chunks. +func fetchAppPoints(client *api.Client, appID int, chunks [][2]string) []api.MetricPoint { + var allPoints []api.MetricPoint + for _, chunk := range chunks { + metrics, err := client.GetMetrics(appID, "throughput", chunk[0], chunk[1]) + if err != nil { + continue + } + series := metrics.Series["throughput"] + allPoints = append(allPoints, series...) + } + return allPoints +} + +// bucketByDay aggregates time-series RPM data into daily transaction counts. +func bucketByDay(points []api.MetricPoint) []dailyUsage { + if len(points) < 2 { + return nil + } + + dayTotals := make(map[string]float64) + + for i := 1; i < len(points); i++ { + t0, err0 := time.Parse(time.RFC3339, points[i-1].Timestamp) + t1, err1 := time.Parse(time.RFC3339, points[i].Timestamp) + if err0 != nil || err1 != nil { + continue + } + intervalMinutes := t1.Sub(t0).Minutes() + if intervalMinutes <= 0 { + continue + } + day := t0.Format("2006-01-02") + dayTotals[day] += points[i-1].Value * intervalMinutes + } + + days := make([]dailyUsage, 0, len(dayTotals)) + for day, txns := range dayTotals { + days = append(days, dailyUsage{ + Date: day, + Transactions: int64(math.Round(txns)), + }) + } + + sort.Slice(days, func(i, j int) bool { + return days[i].Date < days[j].Date + }) + + return days +} + +// splitTimeframe splits a time range into chunks of at most 14 days. +func splitTimeframe(from, to string) [][2]string { + fromTime, _ := time.Parse(time.RFC3339, from) + toTime, _ := time.Parse(time.RFC3339, to) + + maxChunk := 14 * 24 * time.Hour + var chunks [][2]string + + for fromTime.Before(toTime) { + chunkEnd := fromTime.Add(maxChunk) + if chunkEnd.After(toTime) { + chunkEnd = toTime + } + chunks = append(chunks, [2]string{ + fromTime.Format(time.RFC3339), + chunkEnd.Format(time.RFC3339), + }) + fromTime = chunkEnd + } + + return chunks +} + +// fetchAppTransactions fetches throughput data across all time chunks and +// computes total transaction count. +func fetchAppTransactions(client *api.Client, appID int, chunks [][2]string) float64 { + return calculateTransactions(fetchAppPoints(client, appID, chunks)) +} + +// calculateTransactions computes total transactions from RPM time-series data. +// For each consecutive pair of points, it multiplies the RPM value by the +// interval in minutes. +func calculateTransactions(points []api.MetricPoint) float64 { + if len(points) < 2 { + return 0 + } + + var total float64 + for i := 1; i < len(points); i++ { + t0, err0 := time.Parse(time.RFC3339, points[i-1].Timestamp) + t1, err1 := time.Parse(time.RFC3339, points[i].Timestamp) + if err0 != nil || err1 != nil { + continue + } + intervalMinutes := t1.Sub(t0).Minutes() + if intervalMinutes <= 0 { + continue + } + total += points[i-1].Value * intervalMinutes + } + + return total +} + +// printTimeframe prints the timeframe header for usage output. +func printTimeframe(from, to string) { + fromTime, _ := time.Parse(time.RFC3339, from) + toTime, _ := time.Parse(time.RFC3339, to) + fromStr := fromTime.Format("Jan 02, 2006 15:04 UTC") + toStr := toTime.Format("Jan 02, 2006 15:04 UTC") + fmt.Printf("%s\n\n", output.DimStyle.Render(fmt.Sprintf("Timeframe: %s → %s", fromStr, toStr))) +} + +// formatTransactions formats a number with comma separators. +func formatTransactions(n float64) string { + rounded := int64(math.Round(n)) + if rounded == 0 { + return "0" + } + + neg := rounded < 0 + if neg { + rounded = -rounded + } + + s := fmt.Sprintf("%d", rounded) + parts := make([]byte, 0, len(s)+(len(s)-1)/3) + for i, c := range s { + if i > 0 && (len(s)-i)%3 == 0 { + parts = append(parts, ',') + } + parts = append(parts, byte(c)) + } + + if neg { + return "-" + string(parts) + } + return string(parts) +} From c9172395396fb9868d5ecaf3148322a59f1cc963 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Fri, 13 Mar 2026 13:51:58 -0700 Subject: [PATCH 2/6] add usage tests --- cmd/usage_test.go | 300 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 cmd/usage_test.go diff --git a/cmd/usage_test.go b/cmd/usage_test.go new file mode 100644 index 0000000..5ac84a9 --- /dev/null +++ b/cmd/usage_test.go @@ -0,0 +1,300 @@ +package cmd + +import ( + "fmt" + "testing" + + "github.com/scoutapm/scout/internal/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCalculateTransactions(t *testing.T) { + tests := []struct { + name string + points []api.MetricPoint + expected float64 + }{ + { + name: "empty points", + points: nil, + expected: 0, + }, + { + name: "single point", + points: []api.MetricPoint{{Timestamp: "2026-03-01T00:00:00Z", Value: 100}}, + expected: 0, + }, + { + name: "two points one minute apart at 100 rpm", + points: []api.MetricPoint{ + {Timestamp: "2026-03-01T00:00:00Z", Value: 100}, + {Timestamp: "2026-03-01T00:01:00Z", Value: 100}, + }, + expected: 100, // 100 rpm × 1 min + }, + { + name: "two points five minutes apart at 200 rpm", + points: []api.MetricPoint{ + {Timestamp: "2026-03-01T00:00:00Z", Value: 200}, + {Timestamp: "2026-03-01T00:05:00Z", Value: 200}, + }, + expected: 1000, // 200 rpm × 5 min + }, + { + name: "three points with varying rpm", + points: []api.MetricPoint{ + {Timestamp: "2026-03-01T00:00:00Z", Value: 100}, + {Timestamp: "2026-03-01T00:01:00Z", Value: 200}, + {Timestamp: "2026-03-01T00:02:00Z", Value: 300}, + }, + expected: 300, // (100×1) + (200×1) + }, + { + name: "one hour at steady 60 rpm", + points: []api.MetricPoint{ + {Timestamp: "2026-03-01T00:00:00Z", Value: 60}, + {Timestamp: "2026-03-01T01:00:00Z", Value: 60}, + }, + expected: 3600, // 60 rpm × 60 min + }, + { + name: "skips invalid timestamps", + points: []api.MetricPoint{ + {Timestamp: "bad", Value: 100}, + {Timestamp: "2026-03-01T00:01:00Z", Value: 100}, + }, + expected: 0, + }, + { + name: "skips zero or negative intervals", + points: []api.MetricPoint{ + {Timestamp: "2026-03-01T00:01:00Z", Value: 100}, + {Timestamp: "2026-03-01T00:00:00Z", Value: 100}, + }, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := calculateTransactions(tt.points) + assert.InDelta(t, tt.expected, result, 0.01) + }) + } +} + +func TestCalculateTransactionsFullDay(t *testing.T) { + // Simulate 24 hours of 1-minute interval data at a steady 100 rpm + // 1440 intervals × 100 rpm × 1 min = 144,000 transactions + points := make([]api.MetricPoint, 0, 1441) + for i := 0; i < 1440; i++ { + hour := i / 60 + min := i % 60 + points = append(points, api.MetricPoint{ + Timestamp: fmt.Sprintf("2026-03-01T%02d:%02d:00Z", hour, min), + Value: 100, + }) + } + // Final point rolls over to the next day + points = append(points, api.MetricPoint{ + Timestamp: "2026-03-02T00:00:00Z", + Value: 100, + }) + + result := calculateTransactions(points) + assert.InDelta(t, 144000, result, 1) +} + +func TestCalculateTransactionsVaryingRPM(t *testing.T) { + // First hour at 100 rpm, second hour at 200 rpm + // Total: (100 × 60) + (200 × 60) = 18,000 + points := []api.MetricPoint{ + {Timestamp: "2026-03-01T00:00:00Z", Value: 100}, + {Timestamp: "2026-03-01T01:00:00Z", Value: 200}, + {Timestamp: "2026-03-01T02:00:00Z", Value: 200}, + } + + result := calculateTransactions(points) + assert.InDelta(t, 18000, result, 1) +} + +func TestBucketByDay(t *testing.T) { + tests := []struct { + name string + points []api.MetricPoint + expectedDays int + expectedDates []string + expectedTotals []int64 + }{ + { + name: "empty points", + points: nil, + expectedDays: 0, + }, + { + name: "single day", + points: []api.MetricPoint{ + {Timestamp: "2026-03-01T00:00:00Z", Value: 60}, + {Timestamp: "2026-03-01T01:00:00Z", Value: 60}, + {Timestamp: "2026-03-01T02:00:00Z", Value: 60}, + }, + expectedDays: 1, + expectedDates: []string{"2026-03-01"}, + expectedTotals: []int64{7200}, // (60×60) + (60×60) + }, + { + name: "two days", + points: []api.MetricPoint{ + {Timestamp: "2026-03-01T23:00:00Z", Value: 100}, + {Timestamp: "2026-03-02T00:00:00Z", Value: 200}, + {Timestamp: "2026-03-02T01:00:00Z", Value: 200}, + }, + expectedDays: 2, + expectedDates: []string{"2026-03-01", "2026-03-02"}, + // Mar 01: 100 rpm × 60 min = 6000 + // Mar 02: 200 rpm × 60 min = 12000 + expectedTotals: []int64{6000, 12000}, + }, + { + name: "sorted chronologically", + points: []api.MetricPoint{ + {Timestamp: "2026-03-03T00:00:00Z", Value: 10}, + {Timestamp: "2026-03-03T01:00:00Z", Value: 10}, + {Timestamp: "2026-03-01T00:00:00Z", Value: 20}, + {Timestamp: "2026-03-01T01:00:00Z", Value: 20}, + }, + expectedDays: 2, + expectedDates: []string{"2026-03-01", "2026-03-03"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + days := bucketByDay(tt.points) + assert.Len(t, days, tt.expectedDays) + + if tt.expectedDates != nil { + for i, d := range days { + assert.Equal(t, tt.expectedDates[i], d.Date) + } + } + if tt.expectedTotals != nil { + for i, d := range days { + assert.Equal(t, tt.expectedTotals[i], d.Transactions) + } + } + }) + } +} + +func TestBucketByDayBoundary(t *testing.T) { + // A data point at 23:59 should be assigned to that day, not the next + points := []api.MetricPoint{ + {Timestamp: "2026-03-01T23:59:00Z", Value: 100}, + {Timestamp: "2026-03-02T00:00:00Z", Value: 200}, + {Timestamp: "2026-03-02T00:01:00Z", Value: 200}, + } + + days := bucketByDay(points) + require.Len(t, days, 2) + assert.Equal(t, "2026-03-01", days[0].Date) + assert.Equal(t, int64(100), days[0].Transactions) // 100 rpm × 1 min + assert.Equal(t, "2026-03-02", days[1].Date) + assert.Equal(t, int64(200), days[1].Transactions) // 200 rpm × 1 min +} + +func TestSplitTimeframe(t *testing.T) { + tests := []struct { + name string + from string + to string + expectedChunks int + }{ + { + name: "short range single chunk", + from: "2026-03-01T00:00:00Z", + to: "2026-03-02T00:00:00Z", + expectedChunks: 1, + }, + { + name: "exactly 14 days", + from: "2026-03-01T00:00:00Z", + to: "2026-03-15T00:00:00Z", + expectedChunks: 1, + }, + { + name: "15 days splits into two", + from: "2026-03-01T00:00:00Z", + to: "2026-03-16T00:00:00Z", + expectedChunks: 2, + }, + { + name: "30 days splits into three", + from: "2026-02-13T00:00:00Z", + to: "2026-03-15T00:00:00Z", + expectedChunks: 3, + }, + { + name: "same time produces no chunks", + from: "2026-03-01T00:00:00Z", + to: "2026-03-01T00:00:00Z", + expectedChunks: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chunks := splitTimeframe(tt.from, tt.to) + assert.Len(t, chunks, tt.expectedChunks) + + if len(chunks) > 0 { + // First chunk starts at from + assert.Equal(t, tt.from, chunks[0][0]) + // Last chunk ends at to + assert.Equal(t, tt.to, chunks[len(chunks)-1][1]) + // Chunks are contiguous + for i := 1; i < len(chunks); i++ { + assert.Equal(t, chunks[i-1][1], chunks[i][0]) + } + } + }) + } +} + +func TestSplitTimeframeNoChunkExceeds14Days(t *testing.T) { + // 60-day range should produce chunks all <= 14 days + chunks := splitTimeframe("2026-01-01T00:00:00Z", "2026-03-02T00:00:00Z") + require.True(t, len(chunks) > 1) + + for i, chunk := range chunks { + assert.NotEqual(t, chunk[0], chunk[1], "chunk %d start and end should differ", i) + } +} + +func TestFormatTransactions(t *testing.T) { + tests := []struct { + input float64 + expected string + }{ + {0, "0"}, + {1, "1"}, + {999, "999"}, + {1000, "1,000"}, + {1234, "1,234"}, + {12345, "12,345"}, + {123456, "123,456"}, + {1234567, "1,234,567"}, + {462769992, "462,769,992"}, + {0.4, "0"}, + {0.6, "1"}, + {999.9, "1,000"}, + {-1234, "-1,234"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + assert.Equal(t, tt.expected, formatTransactions(tt.input)) + }) + } +} From a2daf7604de1ed262b75de50b74466f8d6211ea3 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Fri, 13 Mar 2026 13:52:11 -0700 Subject: [PATCH 3/6] simplify and refactor usage.go --- cmd/usage.go | 229 +++++++++++++++++++++++---------------------------- 1 file changed, 102 insertions(+), 127 deletions(-) diff --git a/cmd/usage.go b/cmd/usage.go index be9a6f9..7bd8895 100644 --- a/cmd/usage.go +++ b/cmd/usage.go @@ -51,9 +51,9 @@ type dailyAppUsage struct { } type dailyReport struct { - Date string `json:"date"` - Total int64 `json:"total"` - Apps []dailyAppUsage `json:"apps"` + Date string `json:"date"` + Total int64 `json:"total"` + Apps []dailyAppUsage `json:"apps"` } func runUsage(cmd *cobra.Command, args []string) { @@ -80,27 +80,13 @@ func runUsage(cmd *cobra.Command, args []string) { chunks := splitTimeframe(from, to) - var ( - mu sync.Mutex - wg sync.WaitGroup - results []appUsage - ) - - for _, app := range apps { - wg.Add(1) - go func(a api.App) { - defer wg.Done() - txns := fetchAppTransactions(client, a.ID, chunks) - mu.Lock() - results = append(results, appUsage{ - ID: a.ID, - Name: a.Name, - Transactions: txns, - }) - mu.Unlock() - }(app) - } - wg.Wait() + results := fetchAllApps(apps, func(a api.App) appUsage { + return appUsage{ + ID: a.ID, + Name: a.Name, + Transactions: fetchAppTransactions(client, a.ID, chunks), + } + }) if !showAllApps { filtered := results[:0] @@ -148,10 +134,7 @@ func runUsage(cmd *cobra.Command, args []string) { fmt.Println(output.RenderTable(headers, rows)) printTruncated(limit, total) - - if grandTotal > 0 { - fmt.Printf("\nTotal: %s transactions\n", formatTransactions(grandTotal)) - } + printTotalFooter(grandTotal) } func runUsageByDay(cmd *cobra.Command, args []string) { @@ -182,29 +165,7 @@ func runUsageByDayAllApps(client *api.Client, from, to string) { } chunks := splitTimeframe(from, to) - - // Fetch all points for all apps in parallel - type appPoints struct { - points []api.MetricPoint - } - var ( - mu sync.Mutex - wg sync.WaitGroup - allPoints []api.MetricPoint - ) - - for _, app := range apps { - wg.Add(1) - go func(a api.App) { - defer wg.Done() - pts := fetchAppPoints(client, a.ID, chunks) - mu.Lock() - allPoints = append(allPoints, pts...) - mu.Unlock() - }(app) - } - wg.Wait() - + allPoints := fetchAllAppPoints(client, apps, chunks) days := bucketByDay(allPoints) if jsonOutput { @@ -231,10 +192,7 @@ func runUsageByDayAllApps(client *api.Client, from, to string) { fmt.Println(output.RenderTable(headers, rows)) printTruncated(limit, total) - - if grandTotal > 0 { - fmt.Printf("\nTotal: %s transactions\n", formatTransactions(float64(grandTotal))) - } + printTotalFooter(float64(grandTotal)) } func runUsageByDayByApp(client *api.Client, from, to string) { @@ -246,31 +204,16 @@ func runUsageByDayByApp(client *api.Client, from, to string) { chunks := splitTimeframe(from, to) - // Fetch points per app in parallel type appPointsResult struct { app api.App points []api.MetricPoint } - var ( - mu sync.Mutex - wg sync.WaitGroup - appData []appPointsResult - ) - for _, app := range apps { - wg.Add(1) - go func(a api.App) { - defer wg.Done() - pts := fetchAppPoints(client, a.ID, chunks) - mu.Lock() - appData = append(appData, appPointsResult{app: a, points: pts}) - mu.Unlock() - }(app) - } - wg.Wait() + appData := fetchAllApps(apps, func(a api.App) appPointsResult { + return appPointsResult{app: a, points: fetchAppPoints(client, a.ID, chunks)} + }) - // Bucket each app's points by day - // dayMap[date][appID] = transactions + // Bucket each app's points by day using the shared interval logic type appDayInfo struct { appID int appName string @@ -279,22 +222,8 @@ func runUsageByDayByApp(client *api.Client, from, to string) { dayMap := make(map[string][]appDayInfo) for _, ad := range appData { - if len(ad.points) < 2 { - continue - } - for i := 1; i < len(ad.points); i++ { - t0, err0 := time.Parse(time.RFC3339, ad.points[i-1].Timestamp) - t1, err1 := time.Parse(time.RFC3339, ad.points[i].Timestamp) - if err0 != nil || err1 != nil { - continue - } - intervalMinutes := t1.Sub(t0).Minutes() - if intervalMinutes <= 0 { - continue - } - day := t0.Format("2006-01-02") - txns := ad.points[i-1].Value * intervalMinutes - // Find or append + dayTotals := sumIntervalsByDay(ad.points) + for day, txns := range dayTotals { found := false for j := range dayMap[day] { if dayMap[day][j].appID == ad.app.ID { @@ -313,12 +242,7 @@ func runUsageByDayByApp(client *api.Client, from, to string) { } } - // Build sorted day list - var dates []string - for d := range dayMap { - dates = append(dates, d) - } - sort.Strings(dates) + dates := sortedKeys(dayMap) // Fetch top endpoints per app per day in parallel type endpointKey struct { @@ -351,7 +275,6 @@ func runUsageByDayByApp(client *api.Client, from, to string) { } epWg.Wait() - // Build reports reports := make([]dailyReport, 0, len(dates)) for _, date := range dates { appInfos := dayMap[date] @@ -442,7 +365,6 @@ func runUsageByDaySingleApp(client *api.Client, id int, from, to string) { if err != nil || len(endpoints) == 0 { return } - // Endpoints are returned sorted by throughput descending days[idx].TopEndpoint = endpoints[0].Name }(i) } @@ -473,10 +395,40 @@ func runUsageByDaySingleApp(client *api.Client, id int, from, to string) { fmt.Println(output.RenderTable(headers, rows)) printTruncated(limit, total) + printTotalFooter(float64(grandTotal)) +} - if grandTotal > 0 { - fmt.Printf("\nTotal: %s transactions\n", formatTransactions(float64(grandTotal))) +// fetchAllApps runs fn for each app in parallel and collects the results. +func fetchAllApps[T any](apps []api.App, fn func(api.App) T) []T { + var ( + mu sync.Mutex + wg sync.WaitGroup + results []T + ) + for _, app := range apps { + wg.Add(1) + go func(a api.App) { + defer wg.Done() + result := fn(a) + mu.Lock() + results = append(results, result) + mu.Unlock() + }(app) + } + wg.Wait() + return results +} + +// fetchAllAppPoints fetches and merges throughput points for all apps in parallel. +func fetchAllAppPoints(client *api.Client, apps []api.App, chunks [][2]string) []api.MetricPoint { + perApp := fetchAllApps(apps, func(a api.App) []api.MetricPoint { + return fetchAppPoints(client, a.ID, chunks) + }) + var allPoints []api.MetricPoint + for _, pts := range perApp { + allPoints = append(allPoints, pts...) } + return allPoints } // fetchAppPoints fetches throughput time-series data across all time chunks. @@ -487,34 +439,50 @@ func fetchAppPoints(client *api.Client, appID int, chunks [][2]string) []api.Met if err != nil { continue } - series := metrics.Series["throughput"] - allPoints = append(allPoints, series...) + allPoints = append(allPoints, metrics.Series["throughput"]...) } return allPoints } -// bucketByDay aggregates time-series RPM data into daily transaction counts. -func bucketByDay(points []api.MetricPoint) []dailyUsage { - if len(points) < 2 { - return nil +// intervalTransactions computes the transaction count for a consecutive pair +// of metric points (RPM * interval in minutes). Returns 0 if the timestamps +// are invalid or the interval is non-positive. +func intervalTransactions(prev, curr api.MetricPoint) float64 { + t0, err0 := time.Parse(time.RFC3339, prev.Timestamp) + t1, err1 := time.Parse(time.RFC3339, curr.Timestamp) + if err0 != nil || err1 != nil { + return 0 + } + intervalMinutes := t1.Sub(t0).Minutes() + if intervalMinutes <= 0 { + return 0 } + return prev.Value * intervalMinutes +} +// sumIntervalsByDay computes per-day transaction totals from RPM time-series +// data. Returns a map of date strings to transaction counts. +func sumIntervalsByDay(points []api.MetricPoint) map[string]float64 { dayTotals := make(map[string]float64) - for i := 1; i < len(points); i++ { - t0, err0 := time.Parse(time.RFC3339, points[i-1].Timestamp) - t1, err1 := time.Parse(time.RFC3339, points[i].Timestamp) - if err0 != nil || err1 != nil { - continue - } - intervalMinutes := t1.Sub(t0).Minutes() - if intervalMinutes <= 0 { + txns := intervalTransactions(points[i-1], points[i]) + if txns <= 0 { continue } - day := t0.Format("2006-01-02") - dayTotals[day] += points[i-1].Value * intervalMinutes + t0, _ := time.Parse(time.RFC3339, points[i-1].Timestamp) + dayTotals[t0.Format("2006-01-02")] += txns + } + return dayTotals +} + +// bucketByDay aggregates time-series RPM data into daily transaction counts. +func bucketByDay(points []api.MetricPoint) []dailyUsage { + if len(points) < 2 { + return nil } + dayTotals := sumIntervalsByDay(points) + days := make([]dailyUsage, 0, len(dayTotals)) for day, txns := range dayTotals { days = append(days, dailyUsage{ @@ -569,18 +537,8 @@ func calculateTransactions(points []api.MetricPoint) float64 { var total float64 for i := 1; i < len(points); i++ { - t0, err0 := time.Parse(time.RFC3339, points[i-1].Timestamp) - t1, err1 := time.Parse(time.RFC3339, points[i].Timestamp) - if err0 != nil || err1 != nil { - continue - } - intervalMinutes := t1.Sub(t0).Minutes() - if intervalMinutes <= 0 { - continue - } - total += points[i-1].Value * intervalMinutes + total += intervalTransactions(points[i-1], points[i]) } - return total } @@ -593,6 +551,23 @@ func printTimeframe(from, to string) { fmt.Printf("%s\n\n", output.DimStyle.Render(fmt.Sprintf("Timeframe: %s → %s", fromStr, toStr))) } +// printTotalFooter prints the grand total line if non-zero. +func printTotalFooter(grandTotal float64) { + if grandTotal > 0 { + fmt.Printf("\nTotal: %s transactions\n", formatTransactions(grandTotal)) + } +} + +// sortedKeys returns the keys of a map sorted in ascending order. +func sortedKeys[V any](m map[string][]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + // formatTransactions formats a number with comma separators. func formatTransactions(n float64) string { rounded := int64(math.Round(n)) From 7c95dcdae83a346357a9bcdbe64b121187d0c53e Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Fri, 13 Mar 2026 16:06:53 -0700 Subject: [PATCH 4/6] add progress bar to usage responses --- cmd/usage.go | 251 +++++++++++++++++++++++------------- go.mod | 1 + go.sum | 2 + internal/output/progress.go | 84 ++++++++++++ 4 files changed, 251 insertions(+), 87 deletions(-) create mode 100644 internal/output/progress.go diff --git a/cmd/usage.go b/cmd/usage.go index 7bd8895..f31dbf5 100644 --- a/cmd/usage.go +++ b/cmd/usage.go @@ -5,6 +5,7 @@ import ( "math" "sort" "sync" + "sync/atomic" "time" "github.com/scoutapm/scout/internal/api" @@ -56,6 +57,17 @@ type dailyReport struct { Apps []dailyAppUsage `json:"apps"` } +type appDayInfo struct { + appID int + appName string + txns float64 +} + +type endpointKey struct { + date string + appID int +} + func runUsage(cmd *cobra.Command, args []string) { if byDay { runUsageByDay(cmd, args) @@ -80,12 +92,16 @@ func runUsage(cmd *cobra.Command, args []string) { chunks := splitTimeframe(from, to) - results := fetchAllApps(apps, func(a api.App) appUsage { - return appUsage{ - ID: a.ID, - Name: a.Name, - Transactions: fetchAppTransactions(client, a.ID, chunks), - } + results := output.RunWithProgress("Fetching usage data", !jsonOutput, func(update func(float64)) []appUsage { + return fetchAllApps(apps, func(a api.App) appUsage { + return appUsage{ + ID: a.ID, + Name: a.Name, + Transactions: fetchAppTransactions(client, a.ID, chunks), + } + }, func(done, total int) { + update(float64(done) / float64(total)) + }) }) if !showAllApps { @@ -165,7 +181,12 @@ func runUsageByDayAllApps(client *api.Client, from, to string) { } chunks := splitTimeframe(from, to) - allPoints := fetchAllAppPoints(client, apps, chunks) + + allPoints := output.RunWithProgress("Fetching daily usage data", !jsonOutput, func(update func(float64)) []api.MetricPoint { + return fetchAllAppPoints(client, apps, chunks, func(done, total int) { + update(float64(done) / float64(total)) + }) + }) days := bucketByDay(allPoints) if jsonOutput { @@ -203,77 +224,109 @@ func runUsageByDayByApp(client *api.Client, from, to string) { } chunks := splitTimeframe(from, to) + showProgress := !jsonOutput type appPointsResult struct { app api.App points []api.MetricPoint } - appData := fetchAllApps(apps, func(a api.App) appPointsResult { - return appPointsResult{app: a, points: fetchAppPoints(client, a.ID, chunks)} - }) + type byDayByAppResult struct { + appData []appPointsResult + dayMap map[string][]appDayInfo + dates []string + topEndpoints map[endpointKey]string + } - // Bucket each app's points by day using the shared interval logic - type appDayInfo struct { - appID int - appName string - txns float64 - } - dayMap := make(map[string][]appDayInfo) - - for _, ad := range appData { - dayTotals := sumIntervalsByDay(ad.points) - for day, txns := range dayTotals { - found := false - for j := range dayMap[day] { - if dayMap[day][j].appID == ad.app.ID { - dayMap[day][j].txns += txns - found = true - break + result := output.RunWithProgress("Fetching usage data", showProgress, func(update func(float64)) byDayByAppResult { + // Phase 1: Fetch app data (0% - 50%) + appData := fetchAllApps(apps, func(a api.App) appPointsResult { + return appPointsResult{app: a, points: fetchAppPoints(client, a.ID, chunks)} + }, func(done, total int) { + update(float64(done) / float64(total) * 0.5) + }) + + // Bucket each app's points by day using the shared interval logic + dayMap := make(map[string][]appDayInfo) + for _, ad := range appData { + dayTotals := sumIntervalsByDay(ad.points) + for day, txns := range dayTotals { + found := false + for j := range dayMap[day] { + if dayMap[day][j].appID == ad.app.ID { + dayMap[day][j].txns += txns + found = true + break + } + } + if !found { + dayMap[day] = append(dayMap[day], appDayInfo{ + appID: ad.app.ID, + appName: ad.app.Name, + txns: txns, + }) } - } - if !found { - dayMap[day] = append(dayMap[day], appDayInfo{ - appID: ad.app.ID, - appName: ad.app.Name, - txns: txns, - }) } } - } - dates := sortedKeys(dayMap) + dates := sortedKeys(dayMap) - // Fetch top endpoints per app per day in parallel - type endpointKey struct { - date string - appID int - } - topEndpoints := make(map[endpointKey]string) - var epMu sync.Mutex - var epWg sync.WaitGroup + // Phase 2: Fetch top endpoints (50% - 100%) + topEndpoints := make(map[endpointKey]string) + var epMu sync.Mutex + var epWg sync.WaitGroup + var epTotal int64 + var epDone int64 - for _, date := range dates { - for _, info := range dayMap[date] { - if info.txns <= 0 { - continue + for _, date := range dates { + for _, info := range dayMap[date] { + if info.txns > 0 { + epTotal++ + } } - epWg.Add(1) - go func(d string, aID int) { - defer epWg.Done() - dayStart := d + "T00:00:00Z" - dayEnd := d + "T23:59:59Z" - endpoints, err := client.ListEndpoints(aID, dayStart, dayEnd) - if err != nil || len(endpoints) == 0 { - return + } + + for _, date := range dates { + for _, info := range dayMap[date] { + if info.txns <= 0 { + continue } - epMu.Lock() - topEndpoints[endpointKey{date: d, appID: aID}] = endpoints[0].Name - epMu.Unlock() - }(date, info.appID) + epWg.Add(1) + go func(d string, aID int) { + defer epWg.Done() + dayStart := d + "T00:00:00Z" + dayEnd := d + "T23:59:59Z" + endpoints, err := client.ListEndpoints(aID, dayStart, dayEnd) + if err != nil || len(endpoints) == 0 { + n := atomic.AddInt64(&epDone, 1) + if epTotal > 0 { + update(0.5 + float64(n)/float64(epTotal)*0.5) + } + return + } + epMu.Lock() + topEndpoints[endpointKey{date: d, appID: aID}] = endpoints[0].Name + epMu.Unlock() + n := atomic.AddInt64(&epDone, 1) + if epTotal > 0 { + update(0.5 + float64(n)/float64(epTotal)*0.5) + } + }(date, info.appID) + } } - } - epWg.Wait() + epWg.Wait() + + return byDayByAppResult{ + appData: appData, + dayMap: dayMap, + dates: dates, + topEndpoints: topEndpoints, + } + }) + + dayMap := result.dayMap + dates := result.dates + topEndpoints := result.topEndpoints reports := make([]dailyReport, 0, len(dates)) for _, date := range dates { @@ -349,26 +402,42 @@ func runUsageByDayByApp(client *api.Client, from, to string) { func runUsageByDaySingleApp(client *api.Client, id int, from, to string) { chunks := splitTimeframe(from, to) - points := fetchAppPoints(client, id, chunks) - days := bucketByDay(points) + showProgress := !jsonOutput - // Fetch top endpoint for each day in parallel - var wg sync.WaitGroup - for i := range days { - wg.Add(1) - go func(idx int) { - defer wg.Done() - d := days[idx] - dayStart := d.Date + "T00:00:00Z" - dayEnd := d.Date + "T23:59:59Z" - endpoints, err := client.ListEndpoints(id, dayStart, dayEnd) - if err != nil || len(endpoints) == 0 { - return - } - days[idx].TopEndpoint = endpoints[0].Name - }(i) - } - wg.Wait() + days := output.RunWithProgress("Fetching usage data", showProgress, func(update func(float64)) []dailyUsage { + points := fetchAppPoints(client, id, chunks) + days := bucketByDay(points) + + if len(days) == 0 { + return days + } + + // Fetch top endpoint for each day in parallel + update(0.5) + var wg sync.WaitGroup + var done int64 + total := int64(len(days)) + for i := range days { + wg.Add(1) + go func(idx int) { + defer wg.Done() + d := days[idx] + dayStart := d.Date + "T00:00:00Z" + dayEnd := d.Date + "T23:59:59Z" + endpoints, err := client.ListEndpoints(id, dayStart, dayEnd) + if err != nil || len(endpoints) == 0 { + n := atomic.AddInt64(&done, 1) + update(0.5 + float64(n)/float64(total)*0.5) + return + } + days[idx].TopEndpoint = endpoints[0].Name + n := atomic.AddInt64(&done, 1) + update(0.5 + float64(n)/float64(total)*0.5) + }(i) + } + wg.Wait() + return days + }) if jsonOutput { outputJSON(days) @@ -399,12 +468,16 @@ func runUsageByDaySingleApp(client *api.Client, id int, from, to string) { } // fetchAllApps runs fn for each app in parallel and collects the results. -func fetchAllApps[T any](apps []api.App, fn func(api.App) T) []T { +// An optional onProgress callback is called after each app completes with +// (completed, total) counts. +func fetchAllApps[T any](apps []api.App, fn func(api.App) T, onProgress ...func(done, total int)) []T { var ( - mu sync.Mutex - wg sync.WaitGroup - results []T + mu sync.Mutex + wg sync.WaitGroup + results []T + completed int64 ) + total := len(apps) for _, app := range apps { wg.Add(1) go func(a api.App) { @@ -413,6 +486,10 @@ func fetchAllApps[T any](apps []api.App, fn func(api.App) T) []T { mu.Lock() results = append(results, result) mu.Unlock() + if len(onProgress) > 0 && onProgress[0] != nil { + n := int(atomic.AddInt64(&completed, 1)) + onProgress[0](n, total) + } }(app) } wg.Wait() @@ -420,10 +497,10 @@ func fetchAllApps[T any](apps []api.App, fn func(api.App) T) []T { } // fetchAllAppPoints fetches and merges throughput points for all apps in parallel. -func fetchAllAppPoints(client *api.Client, apps []api.App, chunks [][2]string) []api.MetricPoint { +func fetchAllAppPoints(client *api.Client, apps []api.App, chunks [][2]string, onProgress ...func(done, total int)) []api.MetricPoint { perApp := fetchAllApps(apps, func(a api.App) []api.MetricPoint { return fetchAppPoints(client, a.ID, chunks) - }) + }, onProgress...) var allPoints []api.MetricPoint for _, pts := range perApp { allPoints = append(allPoints, pts...) diff --git a/go.mod b/go.mod index e951199..1b5e59f 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect diff --git a/go.sum b/go.sum index a441cbe..e7010df 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= diff --git a/internal/output/progress.go b/internal/output/progress.go new file mode 100644 index 0000000..3aca0a2 --- /dev/null +++ b/internal/output/progress.go @@ -0,0 +1,84 @@ +package output + +import ( + "fmt" + "os" + + "github.com/charmbracelet/bubbles/progress" + tea "github.com/charmbracelet/bubbletea" +) + +type progressMsg float64 + +type doneMsg[T any] struct { + result T +} + +type progressModel[T any] struct { + label string + bar progress.Model + result T + done bool +} + +func (m progressModel[T]) Init() tea.Cmd { + return nil +} + +func (m progressModel[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } + case progressMsg: + return m, m.bar.SetPercent(float64(msg)) + case doneMsg[T]: + m.result = msg.result + m.done = true + return m, tea.Quit + case progress.FrameMsg: + barModel, cmd := m.bar.Update(msg) + m.bar = barModel.(progress.Model) + return m, cmd + } + return m, nil +} + +func (m progressModel[T]) View() string { + if m.done { + return "" + } + return fmt.Sprintf("%s %s", DimStyle.Render(m.label), m.bar.View()) +} + +// RunWithProgress runs fn while displaying an animated progress bar. +// fn receives an update callback that accepts a value between 0.0 and 1.0. +// If showProgress is false, fn is called directly without any UI. +func RunWithProgress[T any](label string, showProgress bool, fn func(update func(float64)) T) T { + if !showProgress { + return fn(func(float64) {}) + } + + m := progressModel[T]{ + label: label, + bar: progress.New(progress.WithSolidFill("#ff5300")), + } + + p := tea.NewProgram(m, tea.WithOutput(os.Stderr)) + + go func() { + result := fn(func(pct float64) { + p.Send(progressMsg(pct)) + }) + p.Send(doneMsg[T]{result: result}) + }() + + finalModel, err := p.Run() + if err != nil { + // If bubbletea fails, fall back to running without progress + return fn(func(float64) {}) + } + + return finalModel.(progressModel[T]).result +} From aaaebe61b7220dff2ec234cf95d89fddcb99692b Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Fri, 13 Mar 2026 16:07:13 -0700 Subject: [PATCH 5/6] add scout usage instructions --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ README.md | 13 +++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..546ab23 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +## [Pending] + +### Added + +- `scout usage` command — show transaction usage across all apps for a given timeframe + - Calculates total transactions per app from throughput time-series data + - Fetches all apps in parallel for fast results + - Automatically splits timeframes longer than 14 days into chunked API requests + - `--by-day` flag for daily transaction totals + - `--by-day --by-app` shows daily breakdown per app with % of day, % of total timeframe, and top endpoints + - `--by-day --app ` shows daily breakdown for a single app with top endpoint + - `--all` flag to include apps with zero usage + - Displays timeframe and summary totals across all output modes + - Supports `--from`, `--to`, `--json`, and `--limit` flags + +## [0.1.0] - 2026-03-11 + +### Added + +- Initial release of the Scout Monitoring CLI +- `scout auth login/logout/status` — API key authentication +- `scout apps list/show` — list and inspect applications +- `scout metrics get` — fetch metric data with ASCII charts (apdex, response_time, response_time_95th, errors, throughput, queue_time) +- `scout endpoints list/metrics` — list endpoints and view endpoint-level metrics +- `scout traces list/show` — list and inspect traces with span trees +- `scout errors list/show/occurrences` — list error groups and view occurrences +- `scout insights list/show` — view performance insights (n_plus_one, memory_bloat, slow_query) +- `scout setup` — show setup instructions for supported frameworks +- Global flags: `--json`, `--app`, `--from`, `--to`, `--no-color`, `--limit` +- Auto-detect piped output and switch to JSON +- Homebrew installation via `scoutapp/tap` +- CI/CD with GitHub Actions and GoReleaser diff --git a/README.md b/README.md index b64bd5d..983613a 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,18 @@ scout errors show 50560 --app 6 scout errors occurrences 50560 --app 6 ``` +### Usage + +```bash +scout usage # Transaction usage across all apps (last 3 hours) +scout usage --from 30d # Last 30 days +scout usage --from 7d --all # Include apps with zero usage +scout usage --by-day --from 30d # Daily totals +scout usage --by-day --by-app --from 30d # Daily breakdown per app with top endpoints +scout usage --by-day --app 6 --from 30d # Daily breakdown for one app with top endpoint +scout usage --from 14d --json # JSON output +``` + ### Insights ```bash @@ -103,6 +115,7 @@ scout setup rails # Show setup docs for a framework | `--app ` | Application ID (or set `default_app_id` in config) | | `--from