Skip to content

Commit

Permalink
log: command initial implementation
Browse files Browse the repository at this point in the history
Part of #251

@TarantoolBot document
Title: `tt log command`

This patch adds new `tt log` command.
By default this command prints last 10 lines of all instances logs.
A user can specify an application or instance name and limit the
number of lines using the `--lines` option.
With the `--follow` flag `tt log` prints logs as the log files
grow.
  • Loading branch information
psergee committed Jun 19, 2024
1 parent b42dba2 commit b13b6d7
Show file tree
Hide file tree
Showing 10 changed files with 994 additions and 0 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- `tt log`: a module for viewing instances logs. Supported options:
* `--lines` number of lines to print.
* `--follow` print appended data as log files grow.

## [2.3.1] - 2024-06-13

### Added
Expand Down
138 changes: 138 additions & 0 deletions cli/cmd/log.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package cmd

import (
"context"
"errors"
"fmt"
"os"
"os/signal"

"github.com/spf13/cobra"
"github.com/tarantool/tt/cli/cmd/internal"
"github.com/tarantool/tt/cli/cmdcontext"
"github.com/tarantool/tt/cli/modules"
"github.com/tarantool/tt/cli/running"
"github.com/tarantool/tt/cli/tail"
"github.com/tarantool/tt/cli/util"
)

var logOpts struct {
nLines int // How many lines to print.
follow bool // Follow logs output.
}

// NewLogCmd creates log command.
func NewLogCmd() *cobra.Command {
var logCmd = &cobra.Command{
Use: "log [<APP_NAME> | <APP_NAME:INSTANCE_NAME>] [flags]",
Short: `Get logs of instance(s)`,
Run: func(cmd *cobra.Command, args []string) {
cmdCtx.CommandName = cmd.Name()
err := modules.RunCmd(&cmdCtx, cmd.CommandPath(), &modulesInfo,
internalLogModule, args)
util.HandleCmdErr(cmd, err)
},
ValidArgsFunction: func(
cmd *cobra.Command,
args []string,
toComplete string) ([]string, cobra.ShellCompDirective) {
return internal.ValidArgsFunction(
cliOpts, &cmdCtx, cmd, toComplete,
running.ExtractAppNames,
running.ExtractInstanceNames)
},
}

logCmd.Flags().IntVarP(&logOpts.nLines, "lines", "n", 10,
"Count of last lines to output")
logCmd.Flags().BoolVarP(&logOpts.follow, "follow", "f", false,
"Output appended data as the log file grows")

return logCmd
}

func printLines(ctx context.Context, in <-chan string) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case line, ok := <-in:
if !ok {
return nil
}
fmt.Println(line)
}
}
}

func follow(instances []running.InstanceCtx, n int) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

nextColor := tail.DefaultColorPicker()
color := nextColor()
const logLinesChannelCapacity = 64
logLines := make(chan string, logLinesChannelCapacity)
tailRoutinesStarted := 0
for _, inst := range instances {
if err := tail.Follow(ctx, logLines,
tail.NewLogFormatter(running.GetAppInstanceName(inst)+": ", color),
inst.Log, n); err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
stop()
return fmt.Errorf("cannot read log file %q: %s", inst.Log, err)
}
tailRoutinesStarted++
color = nextColor()
}

if tailRoutinesStarted > 0 {
return printLines(ctx, logLines)
}
return nil
}

func printLastN(instances []running.InstanceCtx, n int) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

nextColor := tail.DefaultColorPicker()
color := nextColor()
for _, inst := range instances {
logLines, err := tail.TailN(ctx,
tail.NewLogFormatter(running.GetAppInstanceName(inst)+": ", color), inst.Log, n)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
stop()
return fmt.Errorf("cannot read log file %q: %s", inst.Log, err)
}
if err := printLines(ctx, logLines); err != nil {
return err
}
color = nextColor()
}
return nil
}

// internalLogModule is a default log module.
func internalLogModule(cmdCtx *cmdcontext.CmdCtx, args []string) error {
if !isConfigExist(cmdCtx) {
return errNoConfig
}

var err error
var runningCtx running.RunningCtx
if err = running.FillCtx(cliOpts, cmdCtx, &runningCtx, args); err != nil {
return err
}

if logOpts.follow {
return follow(runningCtx.Instances, logOpts.nLines)
}

return printLastN(runningCtx.Instances, logOpts.nLines)
}
1 change: 1 addition & 0 deletions cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ After that tt will be able to manage the application using 'replicaset_example'
NewEnvCmd(),
NewDownloadCmd(),
NewKillCmd(),
NewLogCmd(),
)
if err := injectCmds(rootCmd); err != nil {
panic(err.Error())
Expand Down
24 changes: 24 additions & 0 deletions cli/tail/color.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package tail

import "github.com/fatih/color"

// ColorPicker returns a color.
type ColorPicker func() color.Color

// DefaultColorPicker create a color picker to get a color from a default colors set.
func DefaultColorPicker() ColorPicker {
var colorTable = []color.Color{
*color.New(color.FgCyan),
*color.New(color.FgGreen),
*color.New(color.FgMagenta),
*color.New(color.FgYellow),
*color.New(color.FgBlue),
}

i := 0
return func() color.Color {
color := colorTable[i]
i = (i + 1) % len(colorTable)
return color
}
}
24 changes: 24 additions & 0 deletions cli/tail/color_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package tail_test

import (
"testing"

"github.com/fatih/color"
"github.com/tarantool/tt/cli/tail"
)

func TestDefaultColorPicker(t *testing.T) {
expectedColors := []color.Color{
*color.New(color.FgCyan),
*color.New(color.FgGreen),
*color.New(color.FgMagenta),
*color.New(color.FgYellow),
*color.New(color.FgBlue),
}

colorPicker := tail.DefaultColorPicker()
for i := 0; i < 10; i++ {
got := colorPicker()
got.Equals(&expectedColors[i%len(expectedColors)])
}
}
172 changes: 172 additions & 0 deletions cli/tail/tail.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package tail

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"strings"

"github.com/fatih/color"
"github.com/nxadm/tail"
)

const blockSize = 8192

// LogFormatter is a function used to format log string before output.
type LogFormatter func(str string) string

// NewLogFormatter creates a function to make log prefix colored.
func NewLogFormatter(prefix string, color color.Color) LogFormatter {
buf := strings.Builder{}
buf.Grow(512)
return func(str string) string {
buf.Reset()
color.Fprint(&buf, prefix)
buf.WriteString(str)
return buf.String()
}
}

// newTailReader returns a reader for last count lines.
func newTailReader(ctx context.Context, reader io.ReadSeeker, count int) (io.Reader, int64, error) {
end, err := reader.Seek(0, io.SeekEnd)
if err != nil {
return nil, 0, err
}

if count <= 0 {
return &io.LimitedReader{R: reader, N: 0}, end, nil
}

startPos := end
// Skip last char because it can be new-line. For example, tail reader for 'line\n' and n==1
// should not count last \n as a line.
readOffset := end - 1

buf := make([]byte, blockSize)
linesFound := 0
for readOffset != 0 && linesFound != count {

select {
case <-ctx.Done():
return nil, 0, ctx.Err()
default:
}

limitedReader := io.LimitedReader{R: reader, N: int64(len(buf))}
readOffset -= limitedReader.N
if readOffset < 0 {
limitedReader.N += readOffset
readOffset = 0
}
readOffset, err = reader.Seek(readOffset, io.SeekStart)
if err != nil {
return nil, 0, err
}
readBytes, err := limitedReader.Read(buf)
if err != nil && !errors.Is(err, io.EOF) {
return nil, startPos, fmt.Errorf("failed to read: %s", err)
}
for i := readBytes - 1; i > 0; i-- {
if buf[i] == '\n' {
// In case of \n\n\n bytes, start position should not be moved one byte forward.
if startPos-(readOffset+int64(i)) == 1 {
startPos = readOffset + int64(i)
} else {
startPos = readOffset + int64(i) + 1
}

linesFound++
if linesFound == count {
break
}
}
}
}
if linesFound == count {
reader.Seek(startPos, io.SeekStart)
return &io.LimitedReader{R: reader, N: end - startPos}, startPos, nil
}
reader.Seek(0, io.SeekStart)
return &io.LimitedReader{R: reader, N: end}, 0, nil
}

// TailN calls sends last n lines of the file to the channel.
func TailN(ctx context.Context, logFormatter LogFormatter, fileName string,
n int) (<-chan string, error) {
if n < 0 {
return nil, fmt.Errorf("negative lines count is not supported")
}

file, err := os.Open(fileName)
if err != nil {
return nil, fmt.Errorf("cannot open %q: %w", fileName, err)
}

reader, _, err := newTailReader(ctx, file, n)
if err != nil {
file.Close()
return nil, err
}

scanner := bufio.NewScanner(reader)
out := make(chan string, 8)
go func() {
defer close(out)
defer file.Close()
for scanner.Scan() {
select {
case <-ctx.Done():
return
case out <- logFormatter(scanner.Text()):
}
}
}()
return out, nil
}

// Follow sends to the channel each new line from the file as it grows.
func Follow(ctx context.Context, out chan<- string, logFormatter LogFormatter, fileName string,
n int) error {
file, err := os.Open(fileName)
if err != nil {
return fmt.Errorf("cannot open %q: %w", fileName, err)
}
defer file.Close()

_, startPos, err := newTailReader(ctx, file, n)
if err != nil {
return err
}

t, err := tail.TailFile(fileName, tail.Config{
Location: &tail.SeekInfo{
Offset: startPos,
Whence: io.SeekStart,
},
MustExist: true,
Follow: true,
ReOpen: true,
CompleteLines: false,
Logger: tail.DiscardingLogger})
if err != nil {
return err
}

go func() {
for {
select {
case <-ctx.Done():
t.Stop()
t.Wait()
return
case line := <-t.Lines:
out <- logFormatter(line.Text)
}
}
}()
return nil
}
Loading

0 comments on commit b13b6d7

Please sign in to comment.