Skip to content

Commit 8f167dc

Browse files
committed
feat: improve logging and HTTP server configuration
- Add custom ordered text handler to ensure consistent field ordering (time, level, trace_id) - Remove redundant HTTP request logging (handled by MCP hooks) - Support local timezone in logs with TZ environment variable fallback - Add TOON output format support for HTTP server mode - Extract logging code to separate log.go file for better organization - Update documentation for TZ environment variable
1 parent 9c5e46d commit 8f167dc

9 files changed

Lines changed: 276 additions & 86 deletions

File tree

Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ ENV FLASHDUTY_READ_ONLY=""
3131
ENV FLASHDUTY_TOOLSETS=""
3232
ENV FLASHDUTY_LOG_FILE=""
3333
ENV FLASHDUTY_ENABLE_COMMAND_LOGGING=""
34+
# Set timezone environment variable (can be overridden at runtime)
35+
# Note: distroless images don't include timezone data, so TZ must be set via environment variable
36+
# Example: docker run -e TZ=Asia/Shanghai ...
37+
ENV TZ=""
3438

3539
# Set the entrypoint to the server binary
3640
ENTRYPOINT ["/server/flashduty-mcp-server"]

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ This is the most common method for local configuration, especially in a Docker e
177177
| `FLASHDUTY_BASE_URL` | Flashduty API base URL || `https://api.flashcat.cloud` |
178178
| `FLASHDUTY_LOG_FILE` | Log file path || stderr |
179179
| `FLASHDUTY_ENABLE_COMMAND_LOGGING` | Enable command logging || `false` |
180+
| `TZ` | Timezone for log timestamps (e.g., `Asia/Shanghai`, `America/New_York`) || System default (falls back to `Asia/Shanghai` in containers without timezone data) |
180181

181182
**Docker Example:**
182183

@@ -185,9 +186,12 @@ docker run -i --rm \
185186
-e FLASHDUTY_APP_KEY=<your-app-key> \
186187
-e FLASHDUTY_TOOLSETS="incidents,users,channels" \
187188
-e FLASHDUTY_READ_ONLY=1 \
189+
-e TZ=Asia/Shanghai \
188190
registry.flashcat.cloud/public/flashduty-mcp-server
189191
```
190192

193+
> **Note:** The `TZ` environment variable controls the timezone used for log timestamps. If not set, the server will use the system default timezone, or fall back to `Asia/Shanghai` in containers without timezone data (e.g., distroless images). Common timezone values include `Asia/Shanghai`, `America/New_York`, `Europe/London`, `UTC`, etc.
194+
191195
#### 2. Via Command-Line Arguments
192196

193197
If you build and run the binary directly from the source, you can use command-line arguments.

README_zh.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ Flashduty MCP Server 支持以下配置:
160160
| `FLASHDUTY_BASE_URL` | API 地址 || `https://api.flashcat.cloud` |
161161
| `FLASHDUTY_LOG_FILE` | 日志文件路径 || stderr |
162162
| `FLASHDUTY_ENABLE_COMMAND_LOGGING` | 记录请求日志 || `false` |
163+
| `TZ` | 日志时间戳时区(如 `Asia/Shanghai``America/New_York`|| 系统默认(无时区数据的容器中回退到 `Asia/Shanghai`|
163164

164165
**Docker 示例:**
165166

@@ -168,9 +169,12 @@ docker run -i --rm \
168169
-e FLASHDUTY_APP_KEY=<your-app-key> \
169170
-e FLASHDUTY_TOOLSETS="incidents,users,channels" \
170171
-e FLASHDUTY_READ_ONLY=1 \
172+
-e TZ=Asia/Shanghai \
171173
registry.flashcat.cloud/public/flashduty-mcp-server
172174
```
173175

176+
> **提示:** `TZ` 环境变量用于控制日志时间戳的时区。如果未设置,服务将使用系统默认时区,或在没有时区数据的容器(如 distroless 镜像)中回退到 `Asia/Shanghai`。常用时区值包括 `Asia/Shanghai``America/New_York``Europe/London``UTC` 等。
177+
174178
#### 2. 命令行参数
175179

176180
```bash

cmd/flashduty-mcp-server/main.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/spf13/viper"
1212

1313
"github.com/flashcatcloud/flashduty-mcp-server/internal/flashduty"
14+
1415
flashdutyPkg "github.com/flashcatcloud/flashduty-mcp-server/pkg/flashduty"
1516
)
1617

@@ -76,12 +77,13 @@ var (
7677
Long: `Start a streamable HTTP server.`,
7778
RunE: func(_ *cobra.Command, _ []string) error {
7879
httpServerConfig := flashduty.HTTPServerConfig{
79-
Version: version,
80-
Commit: commit,
81-
Date: date,
82-
BaseURL: viper.GetString("base_url"),
83-
Port: viper.GetString("port"),
84-
LogFilePath: viper.GetString("log-file"),
80+
Version: version,
81+
Commit: commit,
82+
Date: date,
83+
BaseURL: viper.GetString("base_url"),
84+
Port: viper.GetString("port"),
85+
OutputFormat: viper.GetString("output-format"),
86+
LogFilePath: viper.GetString("log-file"),
8587
}
8688
return flashduty.RunHTTPServer(httpServerConfig)
8789
},

e2e/e2e_test.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import (
1212
"testing"
1313
"time"
1414

15+
"github.com/mark3labs/mcp-go/mcp"
16+
"github.com/stretchr/testify/require"
17+
1518
"github.com/flashcatcloud/flashduty-mcp-server/internal/flashduty"
16-
pkgflashduty "github.com/flashcatcloud/flashduty-mcp-server/pkg/flashduty"
1719
"github.com/flashcatcloud/flashduty-mcp-server/pkg/translations"
20+
21+
pkgflashduty "github.com/flashcatcloud/flashduty-mcp-server/pkg/flashduty"
1822
mcpClient "github.com/mark3labs/mcp-go/client"
19-
"github.com/mark3labs/mcp-go/mcp"
20-
"github.com/stretchr/testify/require"
2123
)
2224

2325
var (

internal/flashduty/log.go

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package flashduty
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"log/slog"
8+
"os"
9+
"strconv"
10+
"time"
11+
)
12+
13+
// getLocalTimezone returns the local timezone location.
14+
// Priority:
15+
// 1. TZ environment variable (if set)
16+
// 2. System local timezone (if not UTC)
17+
// 3. Asia/Shanghai as fallback (for containers without timezone data)
18+
func getLocalTimezone() *time.Location {
19+
// First, try TZ environment variable
20+
if tz := os.Getenv("TZ"); tz != "" {
21+
if loc, err := time.LoadLocation(tz); err == nil {
22+
return loc
23+
}
24+
}
25+
26+
// Try to use system local timezone
27+
loc := time.Local
28+
// Check if Local() returns UTC (common in containers without timezone data)
29+
if loc.String() == "UTC" || loc.String() == "Local" {
30+
// Fallback to Asia/Shanghai for containers without timezone data
31+
// Go 1.15+ has built-in timezone data, so this should work even in distroless images
32+
if shanghai, err := time.LoadLocation("Asia/Shanghai"); err == nil {
33+
return shanghai
34+
}
35+
}
36+
return loc
37+
}
38+
39+
// orderedTextHandler is a custom slog handler that orders fields consistently:
40+
// time level trace_id msg [other fields...]
41+
type orderedTextHandler struct {
42+
w io.Writer
43+
opts slog.HandlerOptions
44+
localTZ *time.Location
45+
attrs []slog.Attr // Attributes added via WithAttrs
46+
}
47+
48+
// newOrderedTextHandler creates a new orderedTextHandler with local timezone support.
49+
func newOrderedTextHandler(w io.Writer, level slog.Level) slog.Handler {
50+
return &orderedTextHandler{
51+
w: w,
52+
opts: slog.HandlerOptions{Level: level},
53+
localTZ: getLocalTimezone(),
54+
}
55+
}
56+
57+
// Enabled reports whether the handler handles records at the given level.
58+
func (h *orderedTextHandler) Enabled(ctx context.Context, level slog.Level) bool {
59+
minLevel := h.opts.Level.Level()
60+
return level >= minLevel
61+
}
62+
63+
// Handle processes the log record and writes it with ordered fields.
64+
func (h *orderedTextHandler) Handle(ctx context.Context, r slog.Record) error {
65+
buf := make([]byte, 0, 1024)
66+
67+
// 1. Time (always first)
68+
buf = append(buf, "time="...)
69+
t := r.Time.In(h.localTZ)
70+
buf = t.AppendFormat(buf, time.RFC3339Nano)
71+
buf = append(buf, ' ')
72+
73+
// 2. Level (always second)
74+
buf = append(buf, "level="...)
75+
buf = append(buf, r.Level.String()...)
76+
buf = append(buf, ' ')
77+
78+
// 3. Trace ID (always third, extract from attrs or use empty)
79+
traceID := ""
80+
var otherAttrs []slog.Attr
81+
82+
// Collect all attributes, extracting trace_id
83+
allAttrs := make([]slog.Attr, 0, len(h.attrs)+10)
84+
allAttrs = append(allAttrs, h.attrs...)
85+
86+
r.Attrs(func(a slog.Attr) bool {
87+
allAttrs = append(allAttrs, a)
88+
return true
89+
})
90+
91+
// Process all attributes
92+
for _, a := range allAttrs {
93+
if a.Key == "trace_id" {
94+
if a.Value.Kind() == slog.KindString {
95+
traceID = a.Value.String()
96+
}
97+
continue // Skip, will print separately
98+
}
99+
otherAttrs = append(otherAttrs, a)
100+
}
101+
102+
// Always include trace_id (empty if not present)
103+
buf = append(buf, "trace_id="...)
104+
if traceID != "" {
105+
buf = append(buf, traceID...)
106+
} else {
107+
buf = append(buf, "-"...)
108+
}
109+
buf = append(buf, ' ')
110+
111+
// 4. Message
112+
buf = append(buf, "msg="...)
113+
buf = append(buf, r.Message...)
114+
buf = append(buf, ' ')
115+
116+
// 5. Other attributes
117+
for _, a := range otherAttrs {
118+
buf = append(buf, a.Key...)
119+
buf = append(buf, '=')
120+
buf = appendValue(buf, a.Value)
121+
buf = append(buf, ' ')
122+
}
123+
124+
// Remove trailing space and add newline
125+
if len(buf) > 0 && buf[len(buf)-1] == ' ' {
126+
buf = buf[:len(buf)-1]
127+
}
128+
buf = append(buf, '\n')
129+
130+
_, err := h.w.Write(buf)
131+
return err
132+
}
133+
134+
// WithAttrs returns a new handler with the given attributes.
135+
func (h *orderedTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
136+
newAttrs := make([]slog.Attr, len(h.attrs)+len(attrs))
137+
copy(newAttrs, h.attrs)
138+
copy(newAttrs[len(h.attrs):], attrs)
139+
return &orderedTextHandler{
140+
w: h.w,
141+
opts: h.opts,
142+
localTZ: h.localTZ,
143+
attrs: newAttrs,
144+
}
145+
}
146+
147+
// WithGroup returns a new handler with the given group name.
148+
func (h *orderedTextHandler) WithGroup(name string) slog.Handler {
149+
return &orderedTextHandler{
150+
w: h.w,
151+
opts: h.opts,
152+
localTZ: h.localTZ,
153+
attrs: h.attrs,
154+
}
155+
}
156+
157+
// appendValue appends a value to the buffer in a format similar to slog's text handler.
158+
func appendValue(buf []byte, v slog.Value) []byte {
159+
switch v.Kind() {
160+
case slog.KindString:
161+
buf = append(buf, v.String()...)
162+
case slog.KindInt64:
163+
buf = appendInt(buf, v.Int64())
164+
case slog.KindUint64:
165+
buf = appendUint(buf, v.Uint64())
166+
case slog.KindFloat64:
167+
buf = appendFloat(buf, v.Float64())
168+
case slog.KindBool:
169+
if v.Bool() {
170+
buf = append(buf, "true"...)
171+
} else {
172+
buf = append(buf, "false"...)
173+
}
174+
case slog.KindDuration:
175+
buf = append(buf, v.Duration().String()...)
176+
case slog.KindTime:
177+
buf = append(buf, v.Time().Format(time.RFC3339Nano)...)
178+
case slog.KindAny:
179+
buf = append(buf, fmtAny(v.Any())...)
180+
case slog.KindGroup:
181+
// Groups are flattened
182+
for _, a := range v.Group() {
183+
buf = append(buf, a.Key...)
184+
buf = append(buf, '=')
185+
buf = appendValue(buf, a.Value)
186+
buf = append(buf, ' ')
187+
}
188+
if len(buf) > 0 && buf[len(buf)-1] == ' ' {
189+
buf = buf[:len(buf)-1]
190+
}
191+
}
192+
return buf
193+
}
194+
195+
// fmtAny formats any value as a string.
196+
func fmtAny(v any) string {
197+
if v == nil {
198+
return "nil"
199+
}
200+
if err, ok := v.(error); ok {
201+
return err.Error()
202+
}
203+
return fmt.Sprintf("%+v", v)
204+
}
205+
206+
// appendInt appends an int64 to the buffer.
207+
func appendInt(buf []byte, x int64) []byte {
208+
if x < 0 {
209+
buf = append(buf, '-')
210+
x = -x
211+
}
212+
return appendUint(buf, uint64(x))
213+
}
214+
215+
// appendUint appends a uint64 to the buffer.
216+
func appendUint(buf []byte, x uint64) []byte {
217+
if x == 0 {
218+
return append(buf, '0')
219+
}
220+
var digits [20]byte
221+
i := len(digits)
222+
for x > 0 {
223+
i--
224+
digits[i] = byte(x%10) + '0'
225+
x /= 10
226+
}
227+
return append(buf, digits[i:]...)
228+
}
229+
230+
// appendFloat appends a float64 to the buffer.
231+
func appendFloat(buf []byte, x float64) []byte {
232+
return strconv.AppendFloat(buf, x, 'g', -1, 64)
233+
}

0 commit comments

Comments
 (0)