Skip to content

feat: adds option to tail logs #278

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
May 6, 2025
2 changes: 1 addition & 1 deletion cmd/thv/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func NewRootCmd() *cobra.Command {
rootCmd.AddCommand(proxyCmd)
rootCmd.AddCommand(restartCmd)
rootCmd.AddCommand(newVersionCmd())
rootCmd.AddCommand(newLogsCommand())
rootCmd.AddCommand(logsCommand())
rootCmd.AddCommand(newSecretCommand())

// Skip update check for completion command
Expand Down
130 changes: 73 additions & 57 deletions cmd/thv/app/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,76 +2,92 @@ package app

import (
"context"
"fmt"
"strings"

"github.com/spf13/cobra"
"github.com/spf13/viper"

"github.com/StacklokLabs/toolhive/pkg/container"
"github.com/StacklokLabs/toolhive/pkg/labels"
"github.com/StacklokLabs/toolhive/pkg/logger"
)

func newLogsCommand() *cobra.Command {
return &cobra.Command{
var (
tailFlag bool
)

func logsCommand() *cobra.Command {
logsCommand := &cobra.Command{
Use: "logs [container-name]",
Short: "Output the logs of an MCP server",
Long: `Output the logs of an MCP server managed by Vibe Tool.`,
Args: cobra.ExactArgs(1),
Run: func(_ *cobra.Command, args []string) {
// Get container name
containerName := args[0]

// Create context
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Create container runtime
runtime, err := container.NewFactory().Create(ctx)
if err != nil {
logger.Log.Errorf("failed to create container runtime: %v", err)
return
}

// List containers to find the one with the given name
containers, err := runtime.ListContainers(ctx)
if err != nil {
logger.Log.Errorf("failed to list containers: %v", err)
return
}

// Find the container with the given name
var containerID string
for _, c := range containers {
// Check if the container is managed by Vibe Tool
if !labels.IsToolHiveContainer(c.Labels) {
continue
}

// Check if the container name matches
name := labels.GetContainerName(c.Labels)
if name == "" {
name = c.Name // Fallback to container name
}

// Check if the name matches (exact match or prefix match)
if name == containerName || strings.HasPrefix(c.ID, containerName) {
containerID = c.ID
break
}
}

if containerID == "" {
logger.Log.Infof("container %s not found", containerName)
return
}

logs, err := runtime.ContainerLogs(ctx, containerID)
if err != nil {
logger.Log.Errorf("failed to get container logs: %v", err)
return
}
logger.Log.Infof(logs)

RunE: func(cmd *cobra.Command, args []string) error {
return logsCmdFunc(cmd, args)
},
}

logsCommand.Flags().BoolVarP(&tailFlag, "tail", "t", false, "Tail the logs")
Copy link
Collaborator

Choose a reason for hiding this comment

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

should we make this an IntVarP and pass the 100 that's the default?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Not sure if we can make 100 the default here as that would mean it always tails 100 lines. We need a flag that allows users to tail or just output. However, I can add another variable that is an IntVarP that defaults to 100 and instead outputs the last 100 lines OR it will only tail the last 100 lines.

err := viper.BindPFlag("tail", logsCommand.Flags().Lookup("tail"))
if err != nil {
logger.Log.Errorf("failed to bind flag: %v", err)
}

return logsCommand
}

func logsCmdFunc(_ *cobra.Command, args []string) error {
// Get container name
containerName := args[0]

// Create context
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Create container runtime
runtime, err := container.NewFactory().Create(ctx)
if err != nil {
return fmt.Errorf("failed to create container runtime: %v", err)
}

// List containers to find the one with the given name
containers, err := runtime.ListContainers(ctx)
if err != nil {
return fmt.Errorf("failed to list containers: %w", err)
}

// Find the container with the given name
var containerID string
for _, c := range containers {
// Check if the container is managed by Vibe Tool
if !labels.IsToolHiveContainer(c.Labels) {
continue
}

// Check if the container name matches
name := labels.GetContainerName(c.Labels)
if name == "" {
name = c.Name // Fallback to container name
}

// Check if the name matches (exact match or prefix match)
if name == containerName || strings.HasPrefix(c.ID, containerName) {
containerID = c.ID
break
}
}

if containerID == "" {
logger.Log.Infof("container %s not found", containerName)
return nil
}

tail := viper.GetBool("tail")
logs, err := runtime.ContainerLogs(ctx, containerID, tail)
if err != nil {
return fmt.Errorf("failed to get container logs: %v", err)
}
fmt.Print(logs)
return nil
}
1 change: 1 addition & 0 deletions docs/cli/thv_logs.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion pkg/container/docker/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -452,10 +452,12 @@ func (c *Client) RemoveContainer(ctx context.Context, containerID string) error
}

// ContainerLogs gets container logs
func (c *Client) ContainerLogs(ctx context.Context, containerID string) (string, error) {
func (c *Client) ContainerLogs(ctx context.Context, containerID string, tail bool) (string, error) {
options := container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: tail,
Tail: "100",
}

// Get logs
Expand All @@ -465,6 +467,14 @@ func (c *Client) ContainerLogs(ctx context.Context, containerID string) (string,
}
defer logs.Close()

if tail {
_, err = io.Copy(os.Stdout, logs)
if err != nil && err != io.EOF {
logger.Log.Errorf("Error reading container logs: %v", err)
return "", NewContainerError(err, containerID, fmt.Sprintf("failed to tail container logs: %v", err))
}
}

// Read logs
logBytes, err := io.ReadAll(logs)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion pkg/container/docker/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func (m *ContainerMonitor) monitor(ctx context.Context) {
// Container has exited, get logs and info
// Create a short timeout context for these operations
infoCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
logs, _ := m.runtime.ContainerLogs(infoCtx, m.containerID)
logs, _ := m.runtime.ContainerLogs(infoCtx, m.containerID, false)
info, _ := m.runtime.GetContainerInfo(infoCtx, m.containerID)
cancel() // Always cancel the context to avoid leaks

Expand Down
4 changes: 2 additions & 2 deletions pkg/container/kubernetes/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ func (c *Client) AttachContainer(ctx context.Context, containerID string) (io.Wr
}

// ContainerLogs implements runtime.Runtime.
func (c *Client) ContainerLogs(ctx context.Context, containerID string) (string, error) {
func (c *Client) ContainerLogs(ctx context.Context, containerID string, tail bool) (string, error) {
// In Kubernetes, containerID is the statefulset name
namespace := getCurrentNamespace()

Expand All @@ -224,7 +224,7 @@ func (c *Client) ContainerLogs(ctx context.Context, containerID string) (string,
// Get logs from the pod
logOptions := &corev1.PodLogOptions{
Container: mcpContainerName,
Follow: false,
Follow: tail,
Previous: false,
Timestamps: true,
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/container/runtime/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ type Runtime interface {
RemoveContainer(ctx context.Context, containerID string) error

// ContainerLogs gets container logs
ContainerLogs(ctx context.Context, containerID string) (string, error)
ContainerLogs(ctx context.Context, containerID string, tail bool) (string, error)

// IsContainerRunning checks if a container is running
IsContainerRunning(ctx context.Context, containerID string) (bool, error)
Expand Down
2 changes: 1 addition & 1 deletion pkg/runner/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func (*mockRuntime) RemoveContainer(_ context.Context, _ string) error {
return nil
}

func (*mockRuntime) ContainerLogs(_ context.Context, _ string) (string, error) {
func (*mockRuntime) ContainerLogs(_ context.Context, _ string, _ bool) (string, error) {
return "", nil
}

Expand Down
Loading