feat(log): 访问日志携带 ua,挂 Seq 后控制台放宽到 Info+
- WebConnectLog 访问日志新增 ua 字段(User-Agent 按 rune 截断 200 字符),便于按设备归因 - SetSeqWriter 挂 Seq 后业务控制台由 Warn+ 放宽为 Info+,Debug/SQL 仍只进 Seq 与文件 - 新增 Logger.SetConsoleMinLevel,访问日志控制台单独保持 Warn+ Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -199,6 +199,15 @@ func clientIP(req *http.Request) string {
|
|||||||
return ip
|
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 位)
|
// shortID 生成 n 字节随机 hex(n=6 → 12 位)
|
||||||
func shortID(n int) string {
|
func shortID(n int) string {
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
@@ -466,6 +475,8 @@ func (that *Application) SetConfig(configPath ...string) {
|
|||||||
that.Log.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance)
|
that.Log.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance)
|
||||||
if that.WebConnectLog != nil {
|
if that.WebConnectLog != nil {
|
||||||
that.WebConnectLog.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance)
|
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)
|
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 != "" {
|
if country := req.Header.Get("EO-Client-IPCountry"); country != "" {
|
||||||
evt = evt.Str("ip_country", 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)
|
evt.Msg(context.HandlerStr)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
+11
-3
@@ -26,7 +26,7 @@ HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seq
|
|||||||
- 生成 `request_id`(12 位 hex),回写响应头 `X-Request-Id`
|
- 生成 `request_id`(12 位 hex),回写响应头 `X-Request-Id`
|
||||||
- 将 `sessionId` 前 12 位作为 `sid` 写入请求级日志(脱敏,避免把登录凭据写进 Seq)
|
- 将 `sessionId` 前 12 位作为 `sid` 写入请求级日志(脱敏,避免把登录凭据写进 Seq)
|
||||||
- 业务日志、SQL 日志、访问日志均携带 `sid` / `request_id`
|
- 业务日志、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)
|
## 控制台降噪(配了 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/访问日志等) |
|
| Seq | 按 `logLevel` 全量(Info/Debug/SQL/访问日志等) |
|
||||||
| 本地文件 | 与原先一致,不受控制台过滤影响 |
|
| 本地文件 | 与原先一致,不受控制台过滤影响 |
|
||||||
|
|
||||||
未配置 `seqUrl` 时控制台仍按 `logLevel` 全打。
|
未配置 `seqUrl` 时控制台仍按 `logLevel` 全打。
|
||||||
|
|
||||||
|
框架不新增配置项;如需自定义某个 Logger 的控制台门槛,可在挂 Seq 后调用 `Logger.SetConsoleMinLevel(level zerolog.Level)`(仅影响该 Logger 的控制台出口,不影响 Seq/文件)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 客户端 IP 与地域
|
## 客户端 IP 与地域
|
||||||
@@ -89,7 +95,7 @@ context.LogBind("admin_id", context.Session("admin_id").ToCeilInt64())
|
|||||||
│ fmt.Println("...") ← 捕获后无 sid
|
│ fmt.Println("...") ← 捕获后无 sid
|
||||||
↓
|
↓
|
||||||
multiWriter(hotimev1.5/log/logger.go)
|
multiWriter(hotimev1.5/log/logger.go)
|
||||||
├─ Console(挂 Seq 后 Warn+)→ 终端
|
├─ Console(挂 Seq 后:业务 Info+ / 访问日志 Warn+)→ 终端
|
||||||
├─ FileWriter → 本地文件(按需,全量)
|
├─ FileWriter → 本地文件(按需,全量)
|
||||||
└─ SeqWriter → Seq(全量)
|
└─ SeqWriter → Seq(全量)
|
||||||
│ Write() 只做 channel <- bytes,O(1) 非阻塞
|
│ Write() 只做 channel <- bytes,O(1) 非阻塞
|
||||||
@@ -118,6 +124,7 @@ multiWriter(hotimev1.5/log/logger.go)
|
|||||||
| `ip` | `ip` | 客户端最佳 IP |
|
| `ip` | `ip` | 客户端最佳 IP |
|
||||||
| `ip_chain` | `ip_chain` | 多源去重链路(与 `ip` 不同时才有) |
|
| `ip_chain` | `ip_chain` | 多源去重链路(与 `ip` 不同时才有) |
|
||||||
| `ip_country` | `ip_country` | 有 `EO-Client-IPCountry` 时 |
|
| `ip_country` | `ip_country` | 有 `EO-Client-IPCountry` 时 |
|
||||||
|
| `ua` | `ua` | 访问日志携带,User-Agent 原文截断 200 字符(按 rune 安全截断) |
|
||||||
| 其余自定义字段 | 原字段名 | `LogBind` 追加的字段原样保留 |
|
| 其余自定义字段 | 原字段名 | `LogBind` 追加的字段原样保留 |
|
||||||
| — | `instance` | `ip:port` |
|
| — | `instance` | `ip:port` |
|
||||||
| — | `source` | stdout / stderr / panic 等 |
|
| — | `source` | stdout / stderr / panic 等 |
|
||||||
@@ -137,6 +144,7 @@ multiWriter(hotimev1.5/log/logger.go)
|
|||||||
| stdout | `source = 'stdout'` |
|
| stdout | `source = 'stdout'` |
|
||||||
| panic | `source = 'panic'` |
|
| panic | `source = 'panic'` |
|
||||||
| 组合 | `@l = 'Error' and sid = 'abc123def456'` |
|
| 组合 | `@l = 'Error' and sid = 'abc123def456'` |
|
||||||
|
| 关键字组合其他条件 | `@Message like '%支付失败%' and @l = 'Warning' and instance = '192.168.1.10:8085'` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+15
-6
@@ -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) {
|
func TestSetSeqWriter_QuietsConsoleKeepsSeqFull(t *testing.T) {
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
var gotBody string
|
var gotBody string
|
||||||
@@ -223,13 +225,14 @@ func TestSetSeqWriter_QuietsConsoleKeepsSeqFull(t *testing.T) {
|
|||||||
consoleBuf := &captureBuf{}
|
consoleBuf := &captureBuf{}
|
||||||
l.console.inner = consoleBuf
|
l.console.inner = consoleBuf
|
||||||
l.SetSeqWriter(srv.URL, "", "test:quiet")
|
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")
|
l.Warn().Msg("warn-both")
|
||||||
|
|
||||||
deadline := time.Now().Add(3 * time.Second)
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
mu.Lock()
|
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()
|
mu.Unlock()
|
||||||
if ok {
|
if ok {
|
||||||
break
|
break
|
||||||
@@ -239,22 +242,28 @@ func TestSetSeqWriter_QuietsConsoleKeepsSeqFull(t *testing.T) {
|
|||||||
mu.Lock()
|
mu.Lock()
|
||||||
body := gotBody
|
body := gotBody
|
||||||
mu.Unlock()
|
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)
|
t.Fatalf("Seq incomplete: %q", body)
|
||||||
}
|
}
|
||||||
consoleBuf.mu.Lock()
|
consoleBuf.mu.Lock()
|
||||||
defer consoleBuf.mu.Unlock()
|
defer consoleBuf.mu.Unlock()
|
||||||
for _, line := range consoleBuf.lines {
|
for _, line := range consoleBuf.lines {
|
||||||
if strings.Contains(line, "info-to-seq-only") {
|
if strings.Contains(line, "debug-to-seq-only") {
|
||||||
t.Fatalf("Info leaked to console: %v", consoleBuf.lines)
|
t.Fatalf("Debug leaked to console: %v", consoleBuf.lines)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foundWarn := false
|
foundInfo, foundWarn := false, false
|
||||||
for _, line := range consoleBuf.lines {
|
for _, line := range consoleBuf.lines {
|
||||||
|
if strings.Contains(line, "info-both") {
|
||||||
|
foundInfo = true
|
||||||
|
}
|
||||||
if strings.Contains(line, "warn-both") {
|
if strings.Contains(line, "warn-both") {
|
||||||
foundWarn = true
|
foundWarn = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !foundInfo {
|
||||||
|
t.Fatalf("Info missing on console: %v", consoleBuf.lines)
|
||||||
|
}
|
||||||
if !foundWarn {
|
if !foundWarn {
|
||||||
t.Fatalf("Warn missing on console: %v", consoleBuf.lines)
|
t.Fatalf("Warn missing on console: %v", consoleBuf.lines)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-2
@@ -826,8 +826,18 @@ func (l *Logger) SetSeqWriter(seqUrl, apiKey, instance string) {
|
|||||||
l.seqsMu.Lock()
|
l.seqsMu.Lock()
|
||||||
l.seqs = append(l.seqs, sw)
|
l.seqs = append(l.seqs, sw)
|
||||||
l.seqsMu.Unlock()
|
l.seqsMu.Unlock()
|
||||||
// 挂上 Seq 后控制台只打 Warn+,Info/Debug/SQL/访问日志仍进 Seq 与文件
|
// 挂上 Seq 后控制台放宽到 Info+(Debug/SQL 不上控制台),仍全量进 Seq 与文件;
|
||||||
l.console.setMinLevel(zerolog.WarnLevel)
|
// 访问日志等高噪声 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 安全)
|
// CloseSeq 冲刷并关闭本 Logger 挂载的全部 SeqWriter(幂等、nil 安全)
|
||||||
|
|||||||
+41
@@ -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]))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user