Skip to content
Merged
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
4 changes: 4 additions & 0 deletions cmd/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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)
}),
}
Expand Down
59 changes: 59 additions & 0 deletions internal/container/logfilter.go
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This one has a lot of comments in it :D

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks 😆
Removed.

Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should push for introducing structured logs again. This seems quite brittle, but it's the best we can do at the moment

Copy link
Copy Markdown
Collaborator Author

@carole-lavillonniere carole-lavillonniere Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯 log format could be configurable, plain text is better for humans but json would make it easier to parse them. However I think the biggest issue with the logs at the moment is the fact that are not consistent (some are formatted, others not)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Go PRO-236! 🤖

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
}
135 changes: 135 additions & 0 deletions internal/container/logfilter_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
7 changes: 6 additions & 1 deletion internal/container/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions internal/output/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions internal/output/plain_format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}{},
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions internal/ui/logrender.go
Original file line number Diff line number Diff line change
@@ -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
}
}
48 changes: 48 additions & 0 deletions internal/ui/run_logs.go
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions internal/ui/styles/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
4 changes: 2 additions & 2 deletions internal/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
Expand Down
Loading