diff --git a/cmd/logs.go b/cmd/logs.go index 7d490ce4..faee94e7 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -9,6 +9,7 @@ import ( "github.com/localstack/lstk/internal/env" "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/runtime" + "github.com/localstack/lstk/internal/ui" "github.com/localstack/lstk/internal/telemetry" "github.com/spf13/cobra" ) @@ -32,6 +33,9 @@ func newLogsCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { if err != nil { return fmt.Errorf("failed to get config: %w", err) } + if isInteractiveMode(cfg) { + return ui.RunLogs(cmd.Context(), rt, appConfig.Containers, follow) + } return container.Logs(cmd.Context(), rt, output.NewPlainSink(os.Stdout), appConfig.Containers, follow) }), } diff --git a/internal/container/logfilter.go b/internal/container/logfilter.go new file mode 100644 index 00000000..07a3ac5d --- /dev/null +++ b/internal/container/logfilter.go @@ -0,0 +1,59 @@ +package container + +import ( + "strings" + + "github.com/localstack/lstk/internal/output" +) + +func shouldFilter(line string) bool { + if strings.Contains(line, "Docker not available") { + return true + } + _, logger := parseLogLine(line) + switch { + case logger == "localstack.request.http": + return true + case logger == "l.aws.handlers.internal": + return true + case strings.HasSuffix(logger, ".provider"): + return true + } + return false +} + +// Expected format: 2026-03-16T17:56:00.810 INFO --- [ MainThread] l.p.c.extensions.plugins : message +func parseLogLine(line string) (output.LogLevel, string) { + sepIdx := strings.Index(line, " --- [") + if sepIdx < 0 { + return output.LogLevelUnknown, "" + } + + prefix := strings.TrimSpace(line[:sepIdx]) + level := output.LogLevelUnknown + if spIdx := strings.LastIndex(prefix, " "); spIdx >= 0 { + switch prefix[spIdx+1:] { + case "DEBUG": + level = output.LogLevelDebug + case "INFO": + level = output.LogLevelInfo + case "WARN": + level = output.LogLevelWarn + case "ERROR": + level = output.LogLevelError + } + } + + rest := line[sepIdx+6:] // skip " --- [" + bracketEnd := strings.Index(rest, "]") + if bracketEnd < 0 { + return level, "" + } + afterBracket := strings.TrimSpace(rest[bracketEnd+1:]) + colonIdx := strings.Index(afterBracket, " : ") + if colonIdx < 0 { + return level, "" + } + logger := strings.TrimSpace(afterBracket[:colonIdx]) + return level, logger +} diff --git a/internal/container/logfilter_test.go b/internal/container/logfilter_test.go new file mode 100644 index 00000000..c193c48a --- /dev/null +++ b/internal/container/logfilter_test.go @@ -0,0 +1,135 @@ +package container + +import ( + "testing" + + "github.com/localstack/lstk/internal/output" + "github.com/stretchr/testify/assert" +) + +func TestParseLogLine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + line string + wantLevel output.LogLevel + wantLogger string + }{ + { + name: "INFO aws request", + line: "2026-03-16T17:56:43.472 INFO --- [et.reactor-0] localstack.request.aws : AWS iam.GetUser => 200", + wantLevel: output.LogLevelInfo, + wantLogger: "localstack.request.aws", + }, + { + name: "INFO http request", + line: "2026-03-16T17:58:36.596 INFO --- [et.reactor-1] localstack.request.http : GET /_localstack/resources => 200", + wantLevel: output.LogLevelInfo, + wantLogger: "localstack.request.http", + }, + { + name: "WARN internal handler", + line: "2026-03-16T18:10:35.149 WARN --- [et.reactor-1] l.aws.handlers.internal : Unable to find resource handler for path: /_localstack/appinspector/v1/traces/*/spans", + wantLevel: output.LogLevelWarn, + wantLogger: "l.aws.handlers.internal", + }, + { + name: "INFO provider log", + line: "2026-03-16T17:56:51.985 INFO --- [et.reactor-0] l.p.c.services.s3.provider : Using /tmp/localstack/state/s3 as storage path for s3 assets", + wantLevel: output.LogLevelInfo, + wantLogger: "l.p.c.services.s3.provider", + }, + { + name: "INFO extensions plugin", + line: "2026-03-16T17:56:00.810 INFO --- [ MainThread] l.p.c.extensions.plugins : loaded 0 extensions", + wantLevel: output.LogLevelInfo, + wantLogger: "l.p.c.extensions.plugins", + }, + { + name: "ERROR level", + line: "2026-03-16T17:56:00.810 ERROR --- [ MainThread] localstack.core : something failed", + wantLevel: output.LogLevelError, + wantLogger: "localstack.core", + }, + { + name: "non-standard line", + line: "Docker not available", + wantLevel: output.LogLevelUnknown, + wantLogger: "", + }, + { + name: "empty line", + line: "", + wantLevel: output.LogLevelUnknown, + wantLogger: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + level, logger := parseLogLine(tt.line) + assert.Equal(t, tt.wantLevel, level) + assert.Equal(t, tt.wantLogger, logger) + }) + } +} + +func TestShouldFilter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + line string + filter bool + }{ + { + name: "HTTP request log", + line: "2026-03-16T17:58:36.596 INFO --- [et.reactor-1] localstack.request.http : GET /_localstack/resources => 200", + filter: true, + }, + { + name: "HTTP OPTIONS request", + line: "2026-03-16T18:10:35.142 INFO --- [et.reactor-4] localstack.request.http : OPTIONS /_localstack/appinspector/status => 204", + filter: true, + }, + { + name: "internal handler warning", + line: "2026-03-16T18:10:35.149 WARN --- [et.reactor-1] l.aws.handlers.internal : Unable to find resource handler for path: /_localstack/appinspector/v1/traces/*/spans", + filter: true, + }, + { + name: "provider debug log", + line: "2026-03-16T17:56:51.985 INFO --- [et.reactor-0] l.p.c.services.s3.provider : Using /tmp/localstack/state/s3 as storage path for s3 assets", + filter: true, + }, + { + name: "Docker not available", + line: "Docker not available", + filter: true, + }, + { + name: "AWS request log - kept", + line: "2026-03-16T17:56:43.472 INFO --- [et.reactor-0] localstack.request.aws : AWS iam.GetUser => 200", + filter: false, + }, + { + name: "extensions plugin log - kept", + line: "2026-03-16T17:56:00.810 INFO --- [ MainThread] l.p.c.extensions.plugins : loaded 0 extensions", + filter: false, + }, + { + name: "IAM plugin log - kept", + line: "2026-03-16T17:56:43.344 INFO --- [et.reactor-0] l.p.c.services.iam.plugins : Configured IAM provider to use Advance Policy Simulator", + filter: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.filter, shouldFilter(tt.line)) + }) + } +} diff --git a/internal/container/logs.go b/internal/container/logs.go index 8e0665a0..3647f3c4 100644 --- a/internal/container/logs.go +++ b/internal/container/logs.go @@ -29,7 +29,12 @@ func Logs(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers scanner := bufio.NewScanner(pr) for scanner.Scan() { - output.EmitLogLine(sink, output.LogSourceEmulator, scanner.Text()) + line := scanner.Text() + if shouldFilter(line) { + continue + } + level, _ := parseLogLine(line) + output.EmitLogLine(sink, output.LogSourceEmulator, line, level) } if err := scanner.Err(); err != nil && ctx.Err() == nil { return err diff --git a/internal/output/events.go b/internal/output/events.go index f73b858e..d9ea5116 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -130,9 +130,20 @@ const ( LogSourceNPM = "npm" ) +type LogLevel int + +const ( + LogLevelUnknown LogLevel = iota + LogLevelDebug + LogLevelInfo + LogLevelWarn + LogLevelError +) + type LogLineEvent struct { - Source string // use LogSource* constants + Source string Line string + Level LogLevel } // Emit sends an event to the sink with compile-time type safety via generics. @@ -185,8 +196,8 @@ func EmitAuth(sink Sink, event AuthEvent) { Emit(sink, event) } -func EmitLogLine(sink Sink, source, line string) { - Emit(sink, LogLineEvent{Source: source, Line: line}) +func EmitLogLine(sink Sink, source, line string, level LogLevel) { + Emit(sink, LogLineEvent{Source: source, Line: line, Level: level}) } const DefaultSpinnerMinDuration = 400 * time.Millisecond diff --git a/internal/output/plain_format_test.go b/internal/output/plain_format_test.go index 42168c57..a508e7d9 100644 --- a/internal/output/plain_format_test.go +++ b/internal/output/plain_format_test.go @@ -150,6 +150,18 @@ func TestFormatEventLine(t *testing.T) { want: "", wantOK: false, }, + { + name: "log line event info", + event: LogLineEvent{Source: LogSourceEmulator, Line: "INFO --- [] localstack.core : started", Level: LogLevelInfo}, + want: "INFO --- [] localstack.core : started", + wantOK: true, + }, + { + name: "log line event unknown level", + event: LogLineEvent{Source: LogSourceEmulator, Line: "Docker not available", Level: LogLevelUnknown}, + want: "Docker not available", + wantOK: true, + }, { name: "unsupported event", event: struct{}{}, diff --git a/internal/ui/app.go b/internal/ui/app.go index 9804ed5f..3dc7fe8b 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -166,7 +166,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil case output.LogLineEvent: prefix := styles.Secondary.Render(msg.Source + " | ") - line := styledLine{text: prefix + styles.Message.Render(msg.Line)} + line := styledLine{text: prefix + renderLogLine(msg.Line, msg.Level)} if a.spinner.PendingStop() { a.bufferedLines = appendLine(a.bufferedLines, line) } else { diff --git a/internal/ui/logrender.go b/internal/ui/logrender.go new file mode 100644 index 00000000..e3a29bfb --- /dev/null +++ b/internal/ui/logrender.go @@ -0,0 +1,29 @@ +package ui + +import ( + "strings" + + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/ui/styles" +) + +func renderLogLine(line string, level output.LogLevel) string { + idx := strings.Index(line, " : ") + if idx < 0 { + return renderLogMessage(line, level) + } + meta := line[:idx+3] // up to and including " : " + msg := line[idx+3:] + return styles.Secondary.Render(meta) + renderLogMessage(msg, level) +} + +func renderLogMessage(s string, level output.LogLevel) string { + switch level { + case output.LogLevelWarn: + return styles.Warning.Render(s) + case output.LogLevelError: + return styles.LogError.Render(s) + default: + return s + } +} diff --git a/internal/ui/run_logs.go b/internal/ui/run_logs.go new file mode 100644 index 00000000..2211f05f --- /dev/null +++ b/internal/ui/run_logs.go @@ -0,0 +1,48 @@ +package ui + +import ( + "context" + "errors" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/container" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" +) + +func RunLogs(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, follow bool) error { + ctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + app := NewApp("", "", "", cancel, withoutHeader()) + p := tea.NewProgram(app, tea.WithInput(os.Stdin), tea.WithOutput(os.Stdout)) + runErrCh := make(chan error, 1) + + go func() { + err := container.Logs(ctx, rt, output.NewTUISink(programSender{p: p}), containers, follow) + runErrCh <- err + if err != nil && !errors.Is(err, context.Canceled) { + p.Send(runErrMsg{err: err}) + return + } + p.Send(runDoneMsg{}) + }() + + model, err := p.Run() + if err != nil { + return err + } + + if app, ok := model.(App); ok && app.Err() != nil { + return output.NewSilentError(app.Err()) + } + + runErr := <-runErrCh + if runErr != nil && !errors.Is(runErr, context.Canceled) { + return runErr + } + + return nil +} diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 9ddf1be0..c32e9867 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -47,6 +47,9 @@ var ( Warning = lipgloss.NewStyle(). Foreground(lipgloss.Color("214")) + LogError = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C33820")) + // Secondary/muted style for prefixes Secondary = lipgloss.NewStyle(). Foreground(lipgloss.Color("241")) diff --git a/internal/update/update.go b/internal/update/update.go index 7c73b783..cebd4e82 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -99,7 +99,7 @@ func (w *logLineWriter) Write(p []byte) (int, error) { line := string(w.buf[:i]) w.buf = w.buf[i+1:] if line != "" { - output.EmitLogLine(w.sink, w.source, line) + output.EmitLogLine(w.sink, w.source, line, output.LogLevelUnknown) } } return len(p), nil @@ -110,7 +110,7 @@ func (w *logLineWriter) Flush() { w.mu.Lock() defer w.mu.Unlock() if len(w.buf) > 0 { - output.EmitLogLine(w.sink, w.source, string(w.buf)) + output.EmitLogLine(w.sink, w.source, string(w.buf), output.LogLevelUnknown) w.buf = nil } }