Repository: stern/stern
Pull request #249 (2023-03-12 00:47:35)

Add a short format for timestamps
Fixes https://github.com/stern/stern/issues/247

This PR adds a short format for timestamps. 

It made `--timestamps` take a format, one of "default" or "short" in the form of `--timestamps=<format>`.

- `default`: the original format `2006-01-02T15:04:05.000000000Z07:00` (RFC3339Nano with trailing zeros)
- `short`: the new format `01-02 15:04:05` (time.DateTime without year).

If `--timestamps` is specified but without value, "default" is used to maintain backward compatibility.

## Example

With `--timestamps=short`, stern shows timestamps in the short format.

```
$ stern --timestamps=short -n kube-system ds/kindnet --no-follow --tail 1 --only-log-lines
kindnet-hqn2k kindnet-cni 03-12 09:29:53 I0312 00:29:53.620499       1 main.go:250] Node kind-worker3 has CIDR [10.244.1.0/24]
kindnet-5f4ms kindnet-cni 03-12 09:29:53 I0312 00:29:53.374482       1 main.go:250] Node kind-worker3 has CIDR [10.244.1.0/24]
```

With `--timestamps` (specified but without value), stern shows timestamps in the original format.

```
$ stern --timestamps -n kube-system ds/kindnet --no-follow --tail 1 --only-log-lines
kindnet-hqn2k kindnet-cni 2023-03-12T09:29:33.637476100+09:00 I0312 00:29:33.637404       1 main.go:250] Node kind-worker3 has CIDR [10.244.1.0/24]
kindnet-5f4ms kindnet-cni 2023-03-12T09:29:33.394264800+09:00 I0312 00:29:33.394135       1 main.go:250] Node kind-worker3 has CIDR [10.244.1.0/24]
```



Patch:
diff --git a/README.md b/README.md
index cc6bca6..4b2c9ac 100644
--- a/README.md
+++ b/README.md
@@ -97,7 +97,7 @@ Supported Kubernetes resources are `pod`, `replicationcontroller`, `service`, `d
  `--tail`                    | `-1`      | The number of lines from the end of the logs to show. Defaults to -1, showing all logs.
  `--template`                |           | Template to use for log lines, leave empty to use --output flag.
  `--template-file`, `-T`     |           | Path to template to use for log lines, leave empty to use --output flag. It overrides --template option.
- `--timestamps`, `-t`        | `false`   | Print timestamps.
+ `--timestamps`, `-t`        |           | Print timestamps with the specified format. One of 'default' or 'short'. If specified but without value, 'default' is used.
  `--timezone`                | `Local`   | Set timestamps to specific timezone.
  `--verbosity`               | `0`       | Number of the log level verbosity
  `--version`, `-v`           | `false`   | Print the version and exit.
diff --git a/cmd/cmd.go b/cmd/cmd.go
index 72f7b48..8b532a6 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -46,7 +46,7 @@ type options struct {
 	container           string
 	excludeContainer    []string
 	containerStates     []string
-	timestamps          bool
+	timestamps          string
 	timezone            string
 	since               time.Duration
 	context             string
@@ -90,7 +90,7 @@ func NewOptions(streams genericclioptions.IOStreams) *options {
 		tail:                -1,
 		template:            "",
 		templateFile:        "",
-		timestamps:          false,
+		timestamps:          "",
 		timezone:            "Local",
 		prompt:              false,
 		noFollow:            false,
@@ -218,6 +218,17 @@ func (o *options) sternConfig() (*stern.Config, error) {
 
 	namespaces := makeUnique(o.namespaces)
 
+	var timestampFormat string
+	switch o.timestamps {
+	case "default":
+		timestampFormat = stern.TimestampFormatDefault
+	case "short":
+		timestampFormat = stern.TimestampFormatShort
+	case "":
+	default:
+		return nil, errors.New("timestamps should be one of 'default', or 'short'")
+	}
+
 	// --timezone
 	location, err := time.LoadLocation(o.timezone)
 	if err != nil {
@@ -239,7 +250,8 @@ func (o *options) sternConfig() (*stern.Config, error) {
 		Namespaces:            namespaces,
 		PodQuery:              pod,
 		ExcludePodQuery:       excludePod,
-		Timestamps:            o.timestamps,
+		Timestamps:            timestampFormat != "",
+		TimestampFormat:       timestampFormat,
 		Location:              location,
 		ContainerQuery:        container,
 		ExcludeContainerQuery: excludeContainer,
@@ -306,11 +318,13 @@ func (o *options) AddFlags(fs *pflag.FlagSet) {
 	fs.Int64Var(&o.tail, "tail", o.tail, "The number of lines from the end of the logs to show. Defaults to -1, showing all logs.")
 	fs.StringVar(&o.template, "template", o.template, "Template to use for log lines, leave empty to use --output flag.")
 	fs.StringVarP(&o.templateFile, "template-file", "T", o.templateFile, "Path to template to use for log lines, leave empty to use --output flag. It overrides --template option.")
-	fs.BoolVarP(&o.timestamps, "timestamps", "t", o.timestamps, "Print timestamps.")
+	fs.StringVarP(&o.timestamps, "timestamps", "t", o.timestamps, "Print timestamps with the specified format. One of 'default' or 'short'. If specified but without value, 'default' is used.")
 	fs.StringVar(&o.timezone, "timezone", o.timezone, "Set timestamps to specific timezone.")
 	fs.BoolVar(&o.onlyLogLines, "only-log-lines", o.onlyLogLines, "Print only log lines")
 	fs.IntVar(&o.verbosity, "verbosity", o.verbosity, "Number of the log level verbosity")
 	fs.BoolVarP(&o.version, "version", "v", o.version, "Print the version and exit.")
+
+	fs.Lookup("timestamps").NoOptDefVal = "default"
 }
 
 func (o *options) generateTemplate() (*template.Template, error) {
diff --git a/cmd/flag_completion.go b/cmd/flag_completion.go
index af2ee03..697a3a1 100644
--- a/cmd/flag_completion.go
+++ b/cmd/flag_completion.go
@@ -34,6 +34,7 @@ var flagChoices = map[string][]string{
 	"completion":      []string{"bash", "zsh", "fish"},
 	"container-state": []string{stern.RUNNING, stern.WAITING, stern.TERMINATED, stern.ALL_STATES},
 	"output":          []string{"default", "raw", "json", "extjson", "ppextjson"},
+	"timestamps":      []string{"default", "short"},
 }
 
 func runCompletion(shell string, cmd *cobra.Command, out io.Writer) error {
diff --git a/stern/config.go b/stern/config.go
index 7e82db2..4632c3f 100644
--- a/stern/config.go
+++ b/stern/config.go
@@ -32,6 +32,7 @@ type Config struct {
 	PodQuery              *regexp.Regexp
 	ExcludePodQuery       []*regexp.Regexp
 	Timestamps            bool
+	TimestampFormat       string
 	Location              *time.Location
 	ContainerQuery        *regexp.Regexp
 	ExcludeContainerQuery []*regexp.Regexp
diff --git a/stern/stern.go b/stern/stern.go
index a21b042..8985802 100644
--- a/stern/stern.go
+++ b/stern/stern.go
@@ -96,15 +96,16 @@ func Run(ctx context.Context, config *Config) error {
 	})
 	newTail := func(t *Target) *Tail {
 		return NewTail(client.CoreV1(), t.Node, t.Namespace, t.Pod, t.Container, config.Template, config.Out, config.ErrOut, &TailOptions{
-			Timestamps:   config.Timestamps,
-			Location:     config.Location,
-			SinceSeconds: pointer.Int64(int64(config.Since.Seconds())),
-			Exclude:      config.Exclude,
-			Include:      config.Include,
-			Namespace:    config.AllNamespaces || len(namespaces) > 1,
-			TailLines:    config.TailLines,
-			Follow:       config.Follow,
-			OnlyLogLines: config.OnlyLogLines,
+			Timestamps:      config.Timestamps,
+			TimestampFormat: config.TimestampFormat,
+			Location:        config.Location,
+			SinceSeconds:    pointer.Int64(int64(config.Since.Seconds())),
+			Exclude:         config.Exclude,
+			Include:         config.Include,
+			Namespace:       config.AllNamespaces || len(namespaces) > 1,
+			TailLines:       config.TailLines,
+			Follow:          config.Follow,
+			OnlyLogLines:    config.OnlyLogLines,
 		})
 	}
 
diff --git a/stern/tail.go b/stern/tail.go
index 7b9ab7e..b3e68c6 100644
--- a/stern/tail.go
+++ b/stern/tail.go
@@ -36,6 +36,12 @@ import (
 	"k8s.io/client-go/rest"
 )
 
+// RFC3339Nano with trailing zeros
+const TimestampFormatDefault = "2006-01-02T15:04:05.000000000Z07:00"
+
+// time.DateTime without year
+const TimestampFormatShort = "01-02 15:04:05"
+
 type Tail struct {
 	clientset corev1client.CoreV1Interface
 
@@ -58,8 +64,9 @@ type Tail struct {
 }
 
 type TailOptions struct {
-	Timestamps bool
-	Location   *time.Location
+	Timestamps      bool
+	TimestampFormat string
+	Location        *time.Location
 
 	SinceSeconds *int64
 	SinceTime    *metav1.Time
@@ -131,12 +138,16 @@ func (o TailOptions) HighlightMatchedString(msg string) string {
 	return msg
 }
 
-func (o TailOptions) UpdateTimezone(timestamp string) (string, error) {
+func (o TailOptions) UpdateTimezoneAndFormat(timestamp string) (string, error) {
 	t, err := time.ParseInLocation(time.RFC3339Nano, timestamp, time.UTC)
 	if err != nil {
 		return "", errors.New("missing timestamp")
 	}
-	return t.In(o.Location).Format("2006-01-02T15:04:05.000000000Z07:00"), nil
+	format := TimestampFormatDefault
+	if o.TimestampFormat != "" {
+		format = o.TimestampFormat
+	}
+	return t.In(o.Location).Format(format), nil
 }
 
 // NewTail returns a new tail for a Kubernetes container inside a pod
@@ -327,7 +338,7 @@ func (t *Tail) consumeLine(line string) {
 	msg := t.Options.HighlightMatchedString(content)
 
 	if t.Options.Timestamps {
-		updatedTs, err := t.Options.UpdateTimezone(rfc3339Nano)
+		updatedTs, err := t.Options.UpdateTimezoneAndFormat(rfc3339Nano)
 		if err != nil {
 			t.Print(fmt.Sprintf("[%v] %s", err, line))
 			return


Test patch:
diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go
index 3ca3b33..6786f7b 100644
--- a/cmd/cmd_test.go
+++ b/cmd/cmd_test.go
@@ -388,6 +388,7 @@ func TestOptionsSternConfig(t *testing.T) {
 			PodQuery:              re(""),
 			ExcludePodQuery:       nil,
 			Timestamps:            false,
+			TimestampFormat:       "",
 			Location:              local,
 			ContainerQuery:        re(".*"),
 			ExcludeContainerQuery: nil,
@@ -433,7 +434,7 @@ func TestOptionsSternConfig(t *testing.T) {
 				o.namespaces = []string{"ns1", "ns2"}
 				o.podQuery = "query1"
 				o.excludePod = []string{"exp1", "exp2"}
-				o.timestamps = true
+				o.timestamps = "default"
 				o.timezone = "UTC" // Location
 				o.container = "container1"
 				o.excludeContainer = []string{"exc1", "exc2"}
@@ -463,6 +464,7 @@ func TestOptionsSternConfig(t *testing.T) {
 				c.PodQuery = re("query1")
 				c.ExcludePodQuery = []*regexp.Regexp{re("exp1"), re("exp2")}
 				c.Timestamps = true
+				c.TimestampFormat = stern.TimestampFormatDefault
 				c.Location = utc
 				c.ContainerQuery = re("container1")
 				c.ExcludeContainerQuery = []*regexp.Regexp{re("exc1"), re("exc2")}
@@ -519,6 +521,23 @@ func TestOptionsSternConfig(t *testing.T) {
 			}(),
 			false,
 		},
+		{
+			"timestamp=short",
+			func() *options {
+				o := NewOptions(streams)
+				o.timestamps = "short"
+
+				return o
+			}(),
+			func() *stern.Config {
+				c := defaultConfig()
+				c.Timestamps = true
+				c.TimestampFormat = stern.TimestampFormatShort
+
+				return c
+			}(),
+			false,
+		},
 		{
 			"noFollow has the different default",
 			func() *options {
@@ -689,6 +708,17 @@ func TestOptionsSternConfig(t *testing.T) {
 			nil,
 			true,
 		},
+		{
+			"error timestamps",
+			func() *options {
+				o := NewOptions(streams)
+				o.timestamps = "invalid"
+
+				return o
+			}(),
+			nil,
+			true,
+		},
 	}
 
 	for _, tt := range tests {
diff --git a/stern/tail_test.go b/stern/tail_test.go
index 7bef735..cdedf97 100644
--- a/stern/tail_test.go
+++ b/stern/tail_test.go
@@ -63,23 +63,26 @@ func TestIsIncludeTestOptions(t *testing.T) {
 	}
 }
 
-func TestUpdateTimezone(t *testing.T) {
+func TestUpdateTimezoneAndFormat(t *testing.T) {
 	location, _ := time.LoadLocation("Asia/Tokyo")
 
 	tests := []struct {
 		name     string
+		format   string
 		message  string
 		expected string
 		err      string
 	}{
 		{
 			"normal case",
+			"", // default format is used if empty
 			"2021-04-18T03:54:44.764981564Z",
 			"2021-04-18T12:54:44.764981564+09:00",
 			"",
 		},
 		{
 			"padding",
+			"",
 			"2021-04-18T03:54:44.764981500Z",
 			"2021-04-18T12:54:44.764981500+09:00",
 			"",
@@ -88,28 +91,40 @@ func TestUpdateTimezone(t *testing.T) {
 			"timestamp required on non timestamp message",
 			"",
 			"",
+			"",
 			"missing timestamp",
 		},
 		{
 			"not UTC",
+			"",
 			"2021-08-03T01:26:29.953994922+02:00",
 			"2021-08-03T08:26:29.953994922+09:00",
 			"",
 		},
 		{
 			"RFC3339Nano format removed trailing zeros",
+			"",
 			"2021-06-20T08:20:30.331385Z",
 			"2021-06-20T17:20:30.331385000+09:00",
 			"",
 		},
+		{
+			"Specified the short format",
+			TimestampFormatShort,
+			"2021-06-20T08:20:30.331385Z",
+			"06-20 17:20:30",
+			"",
+		},
 	}
 
-	tailOptions := &TailOptions{
-		Location: location,
-	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			message, err := tailOptions.UpdateTimezone(tt.message)
+			tailOptions := &TailOptions{
+				Location:        location,
+				TimestampFormat: tt.format,
+			}
+
+			message, err := tailOptions.UpdateTimezoneAndFormat(tt.message)
 			if tt.expected != message {
 				t.Errorf("expected %q, but actual %q", tt.expected, message)
 			}