diff --git a/cli/src/cmd/app/commands/logs.go b/cli/src/cmd/app/commands/logs.go index 4240ecb44..ca83db17f 100644 --- a/cli/src/cmd/app/commands/logs.go +++ b/cli/src/cmd/app/commands/logs.go @@ -157,6 +157,7 @@ type logsOptions struct { source string // Log source: "local", "azure", or "all" redact bool alerts bool // Enable log-pattern alert rules + summary bool // Print counts by service and level instead of raw logs } // logsExecutor encapsulates the logs command execution with injectable dependencies. @@ -272,6 +273,9 @@ Examples: # Output errors as JSON with context azd app logs --level error --context 3 --output json + # Show counts by service and level + azd app logs --summary + # View logs from Azure-deployed services azd app logs --source azure @@ -307,6 +311,7 @@ Examples: cmd.Flags().StringVar(&opts.source, "source", "local", "Log source: 'local' (default), 'azure', or 'all'") cmd.Flags().BoolVar(&opts.redact, "redact", false, "Redact secret-shaped values before printing logs") cmd.Flags().BoolVar(&opts.alerts, "alerts", false, "Raise alerts for log lines matching built-in patterns (panic, unhandled exception, fatal)") + cmd.Flags().BoolVar(&opts.summary, "summary", false, "Show counts by service and level instead of raw log entries") registerServiceFlagCompletion(cmd, "service") @@ -385,6 +390,15 @@ func (e *logsExecutor) execute(ctx context.Context, args []string) error { defer cleanup() } + if e.opts.summary { + if collected.HasContext { + displayLogSummary(buildLogSummaryFromContextLogs(collected.EntriesWithContext), outputWriter, e.opts.output == jsonOutputVal) + } else { + displayLogSummary(buildLogSummaryFromEntries(collected.Entries), outputWriter, e.opts.output == jsonOutputVal) + } + return nil + } + // Display logs if collected.HasContext { if e.opts.output == jsonOutputVal { diff --git a/cli/src/cmd/app/commands/logs_command_test.go b/cli/src/cmd/app/commands/logs_command_test.go index bfddd4a6a..d0481a2ea 100644 --- a/cli/src/cmd/app/commands/logs_command_test.go +++ b/cli/src/cmd/app/commands/logs_command_test.go @@ -29,6 +29,7 @@ func TestValidateLogsOptions(t *testing.T) { {"valid since 1h", 100, "text", "all", "1h", "local", 0, false, ""}, {"valid source azure", 100, "text", "all", "", "azure", 0, false, ""}, {"valid source all", 100, "text", "all", "", "all", 0, false, ""}, + {"summary with follow", 100, "text", "all", "", "local", 0, true, "--summary cannot be combined with --follow"}, {"negative tail", -1, "text", "all", "", "local", 0, true, "--tail must be a positive"}, {"invalid format", 100, "xml", "all", "", "local", 0, true, "--output must be"}, {"invalid level", 100, "text", "trace", "", "local", 0, true, "--level must be one of"}, @@ -53,6 +54,10 @@ func TestValidateLogsOptions(t *testing.T) { source: tt.source, contextLines: tt.contextLines, } + if tt.name == "summary with follow" { + opts.summary = true + opts.follow = true + } err := validateLogsOptions(opts) @@ -239,7 +244,7 @@ func TestLogsCommandStructure(t *testing.T) { t.Run("flags exist", func(t *testing.T) { flags := []string{ "follow", "service", "tail", "since", "timestamps", - "no-color", "level", "output", "format", "file", "exclude", "no-builtins", "context", + "no-color", "level", "output", "format", "file", "exclude", "no-builtins", "context", "summary", } for _, flag := range flags { if cmd.Flags().Lookup(flag) == nil { diff --git a/cli/src/cmd/app/commands/logs_filtering.go b/cli/src/cmd/app/commands/logs_filtering.go index e365e1797..a486c3d42 100644 --- a/cli/src/cmd/app/commands/logs_filtering.go +++ b/cli/src/cmd/app/commands/logs_filtering.go @@ -260,6 +260,10 @@ func getFilterConfig(azureYaml *service.AzureYaml, err error) *service.LogFilter // validateLogsOptions validates command-line flag values. func validateLogsOptions(opts *logsOptions) error { + if opts.summary && opts.follow { + return fmt.Errorf("--summary cannot be combined with --follow") + } + // Validate tail is positive if opts.tail < 0 { return fmt.Errorf("--tail must be a positive number, got %d", opts.tail) diff --git a/cli/src/cmd/app/commands/logs_summary.go b/cli/src/cmd/app/commands/logs_summary.go new file mode 100644 index 000000000..5a5a7fb5d --- /dev/null +++ b/cli/src/cmd/app/commands/logs_summary.go @@ -0,0 +1,159 @@ +package commands + +import ( + "encoding/json" + "fmt" + "io" + "os" + "sort" + "strings" + "time" + + "github.com/jongio/azd-app/cli/src/internal/service" +) + +// LogSummaryResult is the machine-readable shape for "logs --summary". +type LogSummaryResult struct { + Total int `json:"total"` + StartTime *time.Time `json:"startTime,omitempty"` + EndTime *time.Time `json:"endTime,omitempty"` + Services []LogServiceSummary `json:"services"` +} + +// LogServiceSummary contains counts for one service. +type LogServiceSummary struct { + Service string `json:"service"` + Info int `json:"info"` + Warn int `json:"warn"` + Error int `json:"error"` + Debug int `json:"debug"` + Unknown int `json:"unknown,omitempty"` + Total int `json:"total"` +} + +type logSummaryEntry struct { + service string + level string + timestamp time.Time +} + +func buildLogSummaryFromEntries(entries []service.LogEntry) LogSummaryResult { + summaryEntries := make([]logSummaryEntry, 0, len(entries)) + for _, entry := range entries { + summaryEntries = append(summaryEntries, logSummaryEntry{ + service: entry.Service, + level: logLevelToString(entry.Level), + timestamp: entry.Timestamp, + }) + } + return buildLogSummary(summaryEntries) +} + +func buildLogSummaryFromContextLogs(entries []LogEntryWithContext) LogSummaryResult { + summaryEntries := make([]logSummaryEntry, 0, len(entries)) + for _, entry := range entries { + summaryEntries = append(summaryEntries, logSummaryEntry{ + service: entry.Service, + level: entry.Level, + timestamp: entry.Timestamp, + }) + } + return buildLogSummary(summaryEntries) +} + +func buildLogSummary(entries []logSummaryEntry) LogSummaryResult { + byService := make(map[string]*LogServiceSummary) + var start, end *time.Time + + for _, entry := range entries { + serviceName := strings.TrimSpace(entry.service) + if serviceName == "" { + serviceName = "(unknown)" + } + + row := byService[serviceName] + if row == nil { + row = &LogServiceSummary{Service: serviceName} + byService[serviceName] = row + } + + switch normalizeLogSummaryLevel(entry.level) { + case "info": + row.Info++ + case logLevelWarn: + row.Warn++ + case statusError: + row.Error++ + case logLevelDebug: + row.Debug++ + default: + row.Unknown++ + } + row.Total++ + + if !entry.timestamp.IsZero() { + ts := entry.timestamp + if start == nil || ts.Before(*start) { + start = &ts + } + if end == nil || ts.After(*end) { + end = &ts + } + } + } + + services := make([]LogServiceSummary, 0, len(byService)) + for _, row := range byService { + services = append(services, *row) + } + sort.Slice(services, func(i, j int) bool { + return services[i].Service < services[j].Service + }) + + return LogSummaryResult{ + Total: len(entries), + StartTime: start, + EndTime: end, + Services: services, + } +} + +func normalizeLogSummaryLevel(level string) string { + switch strings.ToLower(strings.TrimSpace(level)) { + case "info": + return "info" + case logLevelWarn, "warning": + return logLevelWarn + case statusError: + return statusError + case logLevelDebug: + return logLevelDebug + default: + return "unknown" + } +} + +func displayLogSummary(summary LogSummaryResult, w io.Writer, asJSON bool) { + if asJSON { + encoder := json.NewEncoder(w) + if err := encoder.Encode(summary); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to encode log summary: %v\n", err) + } + return + } + + fmt.Fprintf(w, "Total log entries: %d\n", summary.Total) + if summary.StartTime != nil && summary.EndTime != nil { + fmt.Fprintf(w, "Time range: %s to %s\n", summary.StartTime.Format(time.RFC3339), summary.EndTime.Format(time.RFC3339)) + } + if len(summary.Services) == 0 { + return + } + + fmt.Fprintln(w) + fmt.Fprintf(w, "%-20s %6s %6s %6s %6s %8s %6s\n", "Service", "Info", "Warn", "Error", "Debug", "Unknown", "Total") + for _, row := range summary.Services { + fmt.Fprintf(w, "%-20s %6d %6d %6d %6d %8d %6d\n", + row.Service, row.Info, row.Warn, row.Error, row.Debug, row.Unknown, row.Total) + } +} diff --git a/cli/src/cmd/app/commands/logs_summary_test.go b/cli/src/cmd/app/commands/logs_summary_test.go new file mode 100644 index 000000000..c18d0e978 --- /dev/null +++ b/cli/src/cmd/app/commands/logs_summary_test.go @@ -0,0 +1,102 @@ +package commands + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/jongio/azd-app/cli/src/internal/service" +) + +func TestBuildLogSummaryFromEntries(t *testing.T) { + start := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + end := start.Add(2 * time.Minute) + entries := []service.LogEntry{ + {Service: "api", Level: service.LogLevelInfo, Timestamp: start}, + {Service: "api", Level: service.LogLevelError, Timestamp: end}, + {Service: "web", Level: service.LogLevelWarn, Timestamp: start.Add(time.Minute)}, + {Service: "web", Level: service.LogLevelDebug}, + } + + summary := buildLogSummaryFromEntries(entries) + + if summary.Total != 4 { + t.Fatalf("Total = %d, want 4", summary.Total) + } + if summary.StartTime == nil || !summary.StartTime.Equal(start) { + t.Fatalf("StartTime = %v, want %v", summary.StartTime, start) + } + if summary.EndTime == nil || !summary.EndTime.Equal(end) { + t.Fatalf("EndTime = %v, want %v", summary.EndTime, end) + } + if len(summary.Services) != 2 { + t.Fatalf("Services length = %d, want 2", len(summary.Services)) + } + if summary.Services[0].Service != "api" { + t.Fatalf("first service = %q, want api", summary.Services[0].Service) + } + if summary.Services[0].Info != 1 || summary.Services[0].Error != 1 || summary.Services[0].Total != 2 { + t.Fatalf("api counts = %+v", summary.Services[0]) + } + if summary.Services[1].Warn != 1 || summary.Services[1].Debug != 1 || summary.Services[1].Total != 2 { + t.Fatalf("web counts = %+v", summary.Services[1]) + } +} + +func TestBuildLogSummaryFromContextLogs(t *testing.T) { + now := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + entries := []LogEntryWithContext{ + {Service: "api", Level: "error", Timestamp: now}, + {Service: "api", Level: "warning", Timestamp: now.Add(time.Second)}, + } + + summary := buildLogSummaryFromContextLogs(entries) + + if summary.Total != 2 { + t.Fatalf("Total = %d, want 2", summary.Total) + } + if len(summary.Services) != 1 { + t.Fatalf("Services length = %d, want 1", len(summary.Services)) + } + if summary.Services[0].Error != 1 || summary.Services[0].Warn != 1 { + t.Fatalf("counts = %+v", summary.Services[0]) + } +} + +func TestDisplayLogSummary(t *testing.T) { + now := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + summary := LogSummaryResult{ + Total: 2, + StartTime: &now, + EndTime: &now, + Services: []LogServiceSummary{ + {Service: "api", Info: 1, Error: 1, Total: 2}, + }, + } + + t.Run("text", func(t *testing.T) { + var buf bytes.Buffer + displayLogSummary(summary, &buf, false) + out := buf.String() + for _, want := range []string{"Total log entries: 2", "Time range:", "Service", "api"} { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q:\n%s", want, out) + } + } + }) + + t.Run("json", func(t *testing.T) { + var buf bytes.Buffer + displayLogSummary(summary, &buf, true) + + var parsed LogSummaryResult + if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil { + t.Fatalf("failed to unmarshal summary: %v", err) + } + if parsed.Total != 2 || len(parsed.Services) != 1 || parsed.Services[0].Service != "api" { + t.Fatalf("parsed summary = %+v", parsed) + } + }) +}