Skip to content

Commit e81f6f8

Browse files
committed
feat(logs): implement timestamp stripping in log message rendering
1 parent 3a0d60e commit e81f6f8

3 files changed

Lines changed: 146 additions & 1 deletion

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package commands
2+
3+
import "regexp"
4+
5+
// leadingTimestampPatterns are anchored to the start of the string.
6+
// Order matters: more specific patterns (PHP bracket, ISO 8601) are tried first.
7+
var leadingTimestampPatterns = []*regexp.Regexp{
8+
// PHP built-in server / Apache ErrorLog: [Sat Jun 13 09:14:57 2026] or [Sat Jun 3 09:14:57 2026]
9+
regexp.MustCompile(`^\[\w{3} \w{3}\s+\d{1,2} \d{2}:\d{2}:\d{2} \d{4}\]\s*`),
10+
// ISO 8601: 2026-06-13T09:14:57Z 2026-06-13T09:14:57.123Z 2026-06-13T09:14:57+00:00
11+
regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\s*`),
12+
// Date with space separator: 2026-06-13 09:14:57 2026-06-13 09:14:57.123
13+
regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?\s*`),
14+
// nginx / Go stdlib: 2026/06/13 09:14:57
15+
regexp.MustCompile(`^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}\s*`),
16+
}
17+
18+
// stripMessageTimestamp removes a leading timestamp from a log message, returning
19+
// the remainder. If no known timestamp pattern is found at the start of the string,
20+
// the original message is returned unchanged.
21+
func stripMessageTimestamp(message string) string {
22+
for _, re := range leadingTimestampPatterns {
23+
if loc := re.FindStringIndex(message); loc != nil {
24+
return message[loc[1]:]
25+
}
26+
}
27+
return message
28+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package commands
2+
3+
import "testing"
4+
5+
func TestStripMessageTimestamp(t *testing.T) {
6+
tests := []struct {
7+
name string
8+
input string
9+
want string
10+
}{
11+
// PHP built-in server / Apache ErrorLog
12+
{
13+
name: "PHP bracket timestamp two-digit day",
14+
input: "[Sat Jun 13 09:14:57 2026] 172.17.0.1:46058 GET /",
15+
want: "172.17.0.1:46058 GET /",
16+
},
17+
{
18+
name: "PHP bracket timestamp single-digit day space-padded",
19+
input: "[Sat Jun 3 09:14:57 2026] 172.17.0.1:46058 Accepted",
20+
want: "172.17.0.1:46058 Accepted",
21+
},
22+
{
23+
name: "PHP bracket timestamp is entire message",
24+
input: "[Sat Jun 13 09:14:57 2026]",
25+
want: "",
26+
},
27+
28+
// ISO 8601
29+
{
30+
name: "ISO 8601 with Z suffix",
31+
input: "2026-06-13T09:14:57Z GET /api/users 200",
32+
want: "GET /api/users 200",
33+
},
34+
{
35+
name: "ISO 8601 with milliseconds and Z suffix",
36+
input: "2026-06-13T09:14:57.123Z user login succeeded",
37+
want: "user login succeeded",
38+
},
39+
{
40+
name: "ISO 8601 with numeric timezone offset",
41+
input: "2026-06-13T09:14:57+00:00 connection established",
42+
want: "connection established",
43+
},
44+
{
45+
name: "ISO 8601 with no timezone indicator",
46+
input: "2026-06-13T09:14:57 starting worker",
47+
want: "starting worker",
48+
},
49+
50+
// Date with space separator
51+
{
52+
name: "date-space-time timestamp",
53+
input: "2026-06-13 09:14:57 [error] upstream timeout",
54+
want: "[error] upstream timeout",
55+
},
56+
{
57+
name: "date-space-time with milliseconds",
58+
input: "2026-06-13 09:14:57.456 queue flushed",
59+
want: "queue flushed",
60+
},
61+
62+
// nginx / Go stdlib
63+
{
64+
name: "nginx slash-separated timestamp",
65+
input: "2026/06/13 09:14:57 [error] connect() failed",
66+
want: "[error] connect() failed",
67+
},
68+
69+
// Must NOT be stripped
70+
{
71+
name: "Apache combined log — timestamp follows IP, not at start",
72+
input: `172.17.0.1 - - [13/Jun/2026:09:14:57 +0000] "GET / HTTP/1.1" 200 -`,
73+
want: `172.17.0.1 - - [13/Jun/2026:09:14:57 +0000] "GET / HTTP/1.1" 200 -`,
74+
},
75+
{
76+
name: "plain message with no timestamp",
77+
input: "user logged in successfully",
78+
want: "user logged in successfully",
79+
},
80+
{
81+
name: "date only, no time component",
82+
input: "2026-06-13 scheduled maintenance window",
83+
want: "2026-06-13 scheduled maintenance window",
84+
},
85+
{
86+
name: "partial timestamp lookalike",
87+
input: "2026-06-13T09 partial",
88+
want: "2026-06-13T09 partial",
89+
},
90+
91+
// Edge cases
92+
{
93+
name: "empty string",
94+
input: "",
95+
want: "",
96+
},
97+
{
98+
name: "whitespace only",
99+
input: " ",
100+
want: " ",
101+
},
102+
{
103+
name: "trailing whitespace after timestamp is trimmed",
104+
input: "2026/06/13 09:14:57 spaced message",
105+
want: "spaced message",
106+
},
107+
}
108+
109+
for _, tt := range tests {
110+
t.Run(tt.name, func(t *testing.T) {
111+
got := stripMessageTimestamp(tt.input)
112+
if got != tt.want {
113+
t.Errorf("stripMessageTimestamp(%q)\n got %q\n want %q", tt.input, got, tt.want)
114+
}
115+
})
116+
}
117+
}

internal/cli/commands/logs.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ func printLogChunk(chunk client.LogChunk) {
170170
fmt.Printf("%s%s%s %s[%s]%s %s%s%s\n",
171171
colorGray, ts, colorReset,
172172
sourceColor, chunk.Source, colorReset,
173-
levelColor, chunk.Message, colorReset,
173+
levelColor, stripMessageTimestamp(chunk.Message), colorReset,
174174
)
175175
}
176176

0 commit comments

Comments
 (0)