diff --git a/application.go b/application.go index 2c76d98..d709c93 100644 --- a/application.go +++ b/application.go @@ -199,6 +199,15 @@ func clientIP(req *http.Request) string { return ip } +// truncateUA 按 rune 安全截断字符串到 max 长度,避免切坏多字节字符导致 JSON 转义问题 +func truncateUA(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) +} + // shortID 生成 n 字节随机 hex(n=6 → 12 位) func shortID(n int) string { b := make([]byte, n) @@ -466,6 +475,8 @@ func (that *Application) SetConfig(configPath ...string) { that.Log.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance) if that.WebConnectLog != nil { that.WebConnectLog.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance) + // 访问日志量大,挂 Seq 后控制台仍保持 Warn+(业务 Logger 已放宽到 Info+) + that.WebConnectLog.SetConsoleMinLevel(zerolog.WarnLevel) } that.Log.Infof("Seq 日志推送已启动: url=%s instance=%s", seqUrl, instance) } @@ -588,6 +599,9 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) { if country := req.Header.Get("EO-Client-IPCountry"); country != "" { evt = evt.Str("ip_country", country) } + if ua := req.Header.Get("User-Agent"); ua != "" { + evt = evt.Str("ua", truncateUA(ua, 200)) + } evt.Msg(context.HandlerStr) } }() diff --git a/docs/Seq_日志集成.md b/docs/Seq_日志集成.md index 9c7c19d..d008ce2 100644 --- a/docs/Seq_日志集成.md +++ b/docs/Seq_日志集成.md @@ -26,7 +26,7 @@ HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seq - 生成 `request_id`(12 位 hex),回写响应头 `X-Request-Id` - 将 `sessionId` 前 12 位作为 `sid` 写入请求级日志(脱敏,避免把登录凭据写进 Seq) - 业务日志、SQL 日志、访问日志均携带 `sid` / `request_id` -- **控制台自动降噪为 Warn+**(Info/Debug/SQL/访问日志仍进 Seq 与文件,零额外配置) +- **控制台自动降噪**(零额外配置):业务日志(`that.Log`)控制台放宽到 **Info+**(Debug/SQL 不上控制台),访问日志(`that.WebConnectLog`)控制台保持 **Warn+**(量大不上控制台);Debug/SQL/访问日志(Info 级)仍全量进 Seq 与文件 --- @@ -55,14 +55,20 @@ context.LogBind("admin_id", context.Session("admin_id").ToCeilInt64()) ## 控制台降噪(配了 seqUrl) +| Logger | 控制台行为 | +|---|---| +| 业务日志(`that.Log`) | Info+(Info / Warn / Error,含 `Display` 非 0 的 Warn;Debug/SQL 不上控制台) | +| 访问日志(`that.WebConnectLog`) | Warn+(访问日志量大,Info 级不上控制台) | + | 出口 | 行为 | |---|---| -| 控制台 | 仅 Warn / Error(含 `Display` 非 0 的 Warn) | | Seq | 按 `logLevel` 全量(Info/Debug/SQL/访问日志等) | | 本地文件 | 与原先一致,不受控制台过滤影响 | 未配置 `seqUrl` 时控制台仍按 `logLevel` 全打。 +框架不新增配置项;如需自定义某个 Logger 的控制台门槛,可在挂 Seq 后调用 `Logger.SetConsoleMinLevel(level zerolog.Level)`(仅影响该 Logger 的控制台出口,不影响 Seq/文件)。 + --- ## 客户端 IP 与地域 @@ -89,7 +95,7 @@ context.LogBind("admin_id", context.Session("admin_id").ToCeilInt64()) │ fmt.Println("...") ← 捕获后无 sid ↓ multiWriter(hotimev1.5/log/logger.go) - ├─ Console(挂 Seq 后 Warn+)→ 终端 + ├─ Console(挂 Seq 后:业务 Info+ / 访问日志 Warn+)→ 终端 ├─ FileWriter → 本地文件(按需,全量) └─ SeqWriter → Seq(全量) │ Write() 只做 channel <- bytes,O(1) 非阻塞 @@ -118,6 +124,7 @@ multiWriter(hotimev1.5/log/logger.go) | `ip` | `ip` | 客户端最佳 IP | | `ip_chain` | `ip_chain` | 多源去重链路(与 `ip` 不同时才有) | | `ip_country` | `ip_country` | 有 `EO-Client-IPCountry` 时 | +| `ua` | `ua` | 访问日志携带,User-Agent 原文截断 200 字符(按 rune 安全截断) | | 其余自定义字段 | 原字段名 | `LogBind` 追加的字段原样保留 | | — | `instance` | `ip:port` | | — | `source` | stdout / stderr / panic 等 | @@ -137,6 +144,7 @@ multiWriter(hotimev1.5/log/logger.go) | stdout | `source = 'stdout'` | | panic | `source = 'panic'` | | 组合 | `@l = 'Error' and sid = 'abc123def456'` | +| 关键字组合其他条件 | `@Message like '%支付失败%' and @l = 'Warning' and instance = '192.168.1.10:8085'` | --- diff --git a/log/capture_test.go b/log/capture_test.go index 8e75ae0..fc31ee5 100644 --- a/log/capture_test.go +++ b/log/capture_test.go @@ -209,6 +209,8 @@ func TestLevelFilterWriter_WarnOnly(t *testing.T) { } } +// TestSetSeqWriter_QuietsConsoleKeepsSeqFull 验证挂 Seq 后控制台放宽到 Info+: +// Debug 不上控制台,Info/Warn 均可见,Seq 侧全量收到(含 Debug)。 func TestSetSeqWriter_QuietsConsoleKeepsSeqFull(t *testing.T) { var mu sync.Mutex var gotBody string @@ -223,13 +225,14 @@ func TestSetSeqWriter_QuietsConsoleKeepsSeqFull(t *testing.T) { consoleBuf := &captureBuf{} l.console.inner = consoleBuf l.SetSeqWriter(srv.URL, "", "test:quiet") - l.Info().Msg("info-to-seq-only") + l.Debug().Msg("debug-to-seq-only") + l.Info().Msg("info-both") l.Warn().Msg("warn-both") deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { mu.Lock() - ok := strings.Contains(gotBody, "info-to-seq-only") && strings.Contains(gotBody, "warn-both") + ok := strings.Contains(gotBody, "debug-to-seq-only") && strings.Contains(gotBody, "info-both") && strings.Contains(gotBody, "warn-both") mu.Unlock() if ok { break @@ -239,22 +242,28 @@ func TestSetSeqWriter_QuietsConsoleKeepsSeqFull(t *testing.T) { mu.Lock() body := gotBody mu.Unlock() - if !strings.Contains(body, "info-to-seq-only") || !strings.Contains(body, "warn-both") { + if !strings.Contains(body, "debug-to-seq-only") || !strings.Contains(body, "info-both") || !strings.Contains(body, "warn-both") { t.Fatalf("Seq incomplete: %q", body) } consoleBuf.mu.Lock() defer consoleBuf.mu.Unlock() for _, line := range consoleBuf.lines { - if strings.Contains(line, "info-to-seq-only") { - t.Fatalf("Info leaked to console: %v", consoleBuf.lines) + if strings.Contains(line, "debug-to-seq-only") { + t.Fatalf("Debug leaked to console: %v", consoleBuf.lines) } } - foundWarn := false + foundInfo, foundWarn := false, false for _, line := range consoleBuf.lines { + if strings.Contains(line, "info-both") { + foundInfo = true + } if strings.Contains(line, "warn-both") { foundWarn = true } } + if !foundInfo { + t.Fatalf("Info missing on console: %v", consoleBuf.lines) + } if !foundWarn { t.Fatalf("Warn missing on console: %v", consoleBuf.lines) } diff --git a/log/console_level_test.go b/log/console_level_test.go new file mode 100644 index 0000000..da8eb38 --- /dev/null +++ b/log/console_level_test.go @@ -0,0 +1,87 @@ +package log + +import ( + "bytes" + "testing" + + "github.com/rs/zerolog" +) + +// TestSetSeqWriter_ConsoleMinLevelBecomesInfo 验证挂 Seq 后控制台门槛降到 Info(而非 Warn) +func TestSetSeqWriter_ConsoleMinLevelBecomesInfo(t *testing.T) { + l := NewLogger(1, "", 0) + if got := zerolog.Level(l.console.minLevel.Load()); got != zerolog.DebugLevel { + t.Fatalf("初始 console minLevel=%v want Debug", got) + } + // seqUrl 不必可达:SeqWriter 异步 channel 推送,Write 不阻塞 + l.SetSeqWriter("http://127.0.0.1:1", "", "test:level") + defer l.CloseSeq() + if got := zerolog.Level(l.console.minLevel.Load()); got != zerolog.InfoLevel { + t.Fatalf("挂 Seq 后 console minLevel=%v want Info", got) + } +} + +// TestSetConsoleMinLevel_OverridesToWarn 验证导出方法可将 console 门槛单独收紧到 Warn, +// 用于访问日志等高噪声 Logger 在挂 Seq 后仍保持 Warn+。 +func TestSetConsoleMinLevel_OverridesToWarn(t *testing.T) { + l := NewLogger(1, "", 0) + l.SetSeqWriter("http://127.0.0.1:1", "", "test:override") + defer l.CloseSeq() + + l.SetConsoleMinLevel(zerolog.WarnLevel) + if got := zerolog.Level(l.console.minLevel.Load()); got != zerolog.WarnLevel { + t.Fatalf("SetConsoleMinLevel 后 minLevel=%v want Warn", got) + } + + var buf bytes.Buffer + l.console.inner = &buf + if _, err := l.console.WriteLevel(zerolog.InfoLevel, []byte("info-line\n")); err != nil { + t.Fatal(err) + } + if buf.Len() != 0 { + t.Fatalf("Info 应被 Warn 门槛丢弃,实际写入: %q", buf.String()) + } + if _, err := l.console.WriteLevel(zerolog.WarnLevel, []byte("warn-line\n")); err != nil { + t.Fatal(err) + } + if !bytes.Contains(buf.Bytes(), []byte("warn-line")) { + t.Fatalf("Warn 应放行,实际: %q", buf.String()) + } +} + +// TestLevelFilterWriter_WriteLevel_DropAndPass 直接验证 levelFilterWriter.WriteLevel +// 对低于门槛的级别丢弃(但仍返回成功),达到门槛的放行。 +func TestLevelFilterWriter_WriteLevel_DropAndPass(t *testing.T) { + var buf bytes.Buffer + w := newLevelFilterWriter(&buf, zerolog.WarnLevel) + + n, err := w.WriteLevel(zerolog.DebugLevel, []byte("debug-msg")) + if err != nil || n != len("debug-msg") { + t.Fatalf("Debug 丢弃返回值异常: n=%d err=%v", n, err) + } + if buf.Len() != 0 { + t.Fatalf("Debug 不应写入 inner: %q", buf.String()) + } + + n, err = w.WriteLevel(zerolog.InfoLevel, []byte("info-msg")) + if err != nil || n != len("info-msg") { + t.Fatalf("Info 丢弃返回值异常: n=%d err=%v", n, err) + } + if buf.Len() != 0 { + t.Fatalf("Info 不应写入 inner: %q", buf.String()) + } + + if _, err := w.WriteLevel(zerolog.WarnLevel, []byte("warn-msg")); err != nil { + t.Fatal(err) + } + if !bytes.Contains(buf.Bytes(), []byte("warn-msg")) { + t.Fatalf("Warn 应放行: %q", buf.String()) + } + + if _, err := w.WriteLevel(zerolog.ErrorLevel, []byte("error-msg")); err != nil { + t.Fatal(err) + } + if !bytes.Contains(buf.Bytes(), []byte("error-msg")) { + t.Fatalf("Error 应放行: %q", buf.String()) + } +} diff --git a/log/logger.go b/log/logger.go index ba465cb..22bbf76 100644 --- a/log/logger.go +++ b/log/logger.go @@ -826,8 +826,18 @@ func (l *Logger) SetSeqWriter(seqUrl, apiKey, instance string) { l.seqsMu.Lock() l.seqs = append(l.seqs, sw) l.seqsMu.Unlock() - // 挂上 Seq 后控制台只打 Warn+,Info/Debug/SQL/访问日志仍进 Seq 与文件 - l.console.setMinLevel(zerolog.WarnLevel) + // 挂上 Seq 后控制台放宽到 Info+(Debug/SQL 不上控制台),仍全量进 Seq 与文件; + // 访问日志等高噪声 Logger 可再调用 SetConsoleMinLevel 单独收紧。 + l.console.setMinLevel(zerolog.InfoLevel) +} + +// SetConsoleMinLevel 单独设置本 Logger 控制台出口的最低级别,不影响 Seq/文件等其他出口。 +// 用于访问日志等高噪声场景在挂 Seq 后仍保持更高的控制台门槛(如 Warn+)。 +func (l *Logger) SetConsoleMinLevel(level zerolog.Level) { + if l == nil { + return + } + l.console.setMinLevel(level) } // CloseSeq 冲刷并关闭本 Logger 挂载的全部 SeqWriter(幂等、nil 安全) diff --git a/ua_test.go b/ua_test.go new file mode 100644 index 0000000..489d838 --- /dev/null +++ b/ua_test.go @@ -0,0 +1,41 @@ +package hotime + +import "testing" + +func TestTruncateUA_ShortUnchanged(t *testing.T) { + ua := "Mozilla/5.0 (Test)" + if got := truncateUA(ua, 200); got != ua { + t.Fatalf("短串不应被截断: got=%q want=%q", got, ua) + } +} + +func TestTruncateUA_LongTruncatedTo200(t *testing.T) { + long := "" + for i := 0; i < 300; i++ { + long += "a" + } + got := truncateUA(long, 200) + if len([]rune(got)) != 200 { + t.Fatalf("截断后长度=%d want 200", len([]rune(got))) + } + if got != long[:200] { + t.Fatalf("ASCII 截断内容不符: got=%q", got) + } +} + +func TestTruncateUA_MultiByteNotBroken(t *testing.T) { + // 前 199 个 ASCII + 1 个多字节字符("中"占 3 字节),max=200 时应完整保留该 rune 而非切碎字节 + prefix := "" + for i := 0; i < 199; i++ { + prefix += "a" + } + long := prefix + "中" + "extra-should-be-cut" + got := truncateUA(long, 200) + runes := []rune(got) + if len(runes) != 200 { + t.Fatalf("按 rune 截断后应为 200 个字符, got=%d (%q)", len(runes), got) + } + if runes[199] != '中' { + t.Fatalf("第 200 个字符应为完整的 '中',实际=%q", string(runes[199])) + } +}