Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions cli/src/cmd/app/commands/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion cli/src/cmd/app/commands/logs_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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)

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions cli/src/cmd/app/commands/logs_filtering.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
159 changes: 159 additions & 0 deletions cli/src/cmd/app/commands/logs_summary.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
102 changes: 102 additions & 0 deletions cli/src/cmd/app/commands/logs_summary_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
Loading