feat(log): Seq 会话追踪、控制台降噪与推送质量增强
请求日志自动带 sid/request_id,支持 LogBind;挂 Seq 后控制台仅 Warn+,Seq 仍全量;并修毫秒时间戳、@m、失败重试与停机 flush。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+61
-21
@@ -2,7 +2,9 @@ package hotime
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
stdlog "log"
|
stdlog "log"
|
||||||
@@ -25,8 +27,8 @@ import (
|
|||||||
. "code.hoteas.com/golang/hotime/db"
|
. "code.hoteas.com/golang/hotime/db"
|
||||||
"code.hoteas.com/golang/hotime/log"
|
"code.hoteas.com/golang/hotime/log"
|
||||||
mysql "github.com/go-sql-driver/mysql"
|
mysql "github.com/go-sql-driver/mysql"
|
||||||
logrus "github.com/sirupsen/logrus"
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
|
logrus "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Application struct {
|
type Application struct {
|
||||||
@@ -97,10 +99,45 @@ func (that *Application) initiateGracefulShutdown(drain, shutdown time.Duration)
|
|||||||
} else {
|
} else {
|
||||||
that.Log.Infof("服务已安全关闭")
|
that.Log.Infof("服务已安全关闭")
|
||||||
}
|
}
|
||||||
|
// 冲刷 Seq 队列,避免停机前后关键日志丢失
|
||||||
|
that.Log.CloseSeq()
|
||||||
|
if that.WebConnectLog != nil {
|
||||||
|
that.WebConnectLog.CloseSeq()
|
||||||
|
}
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clientIP 取客户端真实 IP:X-Real-IP → X-Forwarded-For 第一段 → RemoteAddr
|
||||||
|
func clientIP(req *http.Request) string {
|
||||||
|
if req == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if ip := strings.TrimSpace(req.Header.Get("X-Real-IP")); ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
if xff := req.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
if i := strings.Index(xff, ","); i >= 0 {
|
||||||
|
return strings.TrimSpace(xff[:i])
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(xff)
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(req.RemoteAddr)
|
||||||
|
if err == nil && host != "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return req.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortID 生成 n 字节随机 hex(n=6 → 12 位)
|
||||||
|
func shortID(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return Md5(strconv.Itoa(Rand(10)))[:n*2]
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
// Run 启动实例
|
// Run 启动实例
|
||||||
func (that *Application) Run(router Router) {
|
func (that *Application) Run(router Router) {
|
||||||
//如果没有设置配置自动生成配置
|
//如果没有设置配置自动生成配置
|
||||||
@@ -438,10 +475,22 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
unescapeUrl = req.RequestURI
|
unescapeUrl = req.RequestURI
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 请求级追踪:sid=sessionId 前 12 位(脱敏),request_id 回写响应头
|
||||||
|
requestId := shortID(6)
|
||||||
|
sid := sessionId
|
||||||
|
if len(sid) > 12 {
|
||||||
|
sid = sid[:12]
|
||||||
|
}
|
||||||
|
w.Header().Set("X-Request-Id", requestId)
|
||||||
|
reqLog := that.Log.WithFields("sid", sid, "request_id", requestId)
|
||||||
|
dbCopy := that.Db
|
||||||
|
dbCopy.Log = reqLog
|
||||||
|
|
||||||
//访问实例
|
//访问实例
|
||||||
context := Context{SessionIns: SessionIns{SessionId: sessionId, HoTimeCache: that.HoTimeCache},
|
context := Context{SessionIns: SessionIns{SessionId: sessionId, HoTimeCache: that.HoTimeCache},
|
||||||
Resp: w, Req: req, Application: that, RouterString: s, Config: that.Config, Db: &that.Db,
|
Resp: w, Req: req, Application: that, RouterString: s, Config: that.Config, Db: &dbCopy,
|
||||||
HandlerStr: unescapeUrl}
|
HandlerStr: unescapeUrl, Logger: reqLog}
|
||||||
//header默认设置
|
//header默认设置
|
||||||
header := w.Header()
|
header := w.Header()
|
||||||
header.Set("Content-Type", "text/html; charset=utf-8")
|
header.Set("Content-Type", "text/html; charset=utf-8")
|
||||||
@@ -455,26 +504,17 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
|||||||
defer func() {
|
defer func() {
|
||||||
//是否展示日志
|
//是否展示日志
|
||||||
if that.WebConnectLog != nil {
|
if that.WebConnectLog != nil {
|
||||||
|
evt := that.WebConnectLog.Info().
|
||||||
//负载均衡优化
|
Str("ip", clientIP(req)).
|
||||||
ipStr := ""
|
|
||||||
if req.Header.Get("X-Forwarded-For") != "" {
|
|
||||||
ipStr = req.Header.Get("X-Forwarded-For")
|
|
||||||
} else if req.Header.Get("X-Real-IP") != "" {
|
|
||||||
ipStr = req.Header.Get("X-Real-IP")
|
|
||||||
}
|
|
||||||
//负载均衡优化
|
|
||||||
if ipStr == "" {
|
|
||||||
//RemoteAddr := that.Req.RemoteAddr
|
|
||||||
ipStr = Substr(context.Req.RemoteAddr, 0, strings.Index(context.Req.RemoteAddr, ":"))
|
|
||||||
}
|
|
||||||
|
|
||||||
that.WebConnectLog.Info().
|
|
||||||
Str("ip", ipStr).
|
|
||||||
Str("method", context.Req.Method).
|
Str("method", context.Req.Method).
|
||||||
|
Str("sid", sid).
|
||||||
|
Str("request_id", requestId).
|
||||||
Float64("cost_ms", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00).
|
Float64("cost_ms", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00).
|
||||||
Float64("size_kb", ObjToFloat64(context.DataSize)/1000.00).
|
Float64("size_kb", ObjToFloat64(context.DataSize)/1000.00)
|
||||||
Msg(context.HandlerStr)
|
if country := req.Header.Get("EO-Client-IPCountry"); country != "" {
|
||||||
|
evt = evt.Str("ip_country", country)
|
||||||
|
}
|
||||||
|
evt.Msg(context.HandlerStr)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
+32
-16
@@ -6,19 +6,20 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
. "code.hoteas.com/golang/hotime/common"
|
. "code.hoteas.com/golang/hotime/common"
|
||||||
. "code.hoteas.com/golang/hotime/db"
|
. "code.hoteas.com/golang/hotime/db"
|
||||||
|
htlog "code.hoteas.com/golang/hotime/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Context struct {
|
type Context struct {
|
||||||
*Application
|
*Application
|
||||||
Resp http.ResponseWriter
|
Resp http.ResponseWriter
|
||||||
Req *http.Request
|
Req *http.Request
|
||||||
Log Map //日志有则创建
|
Log Map //业务 logs 表字段(有则写入),与 Logger 不同
|
||||||
|
Logger *htlog.Logger // 请求级日志(带 sid/request_id),业务与 SQL 共用
|
||||||
RouterString []string
|
RouterString []string
|
||||||
Config Map
|
Config Map
|
||||||
Db *HoTimeDB
|
Db *HoTimeDB
|
||||||
@@ -60,7 +61,7 @@ func (that *Context) Display(statu int, data interface{}) {
|
|||||||
//兼容android等需要json转对象的服务
|
//兼容android等需要json转对象的服务
|
||||||
resp["error"] = temp
|
resp["error"] = temp
|
||||||
|
|
||||||
that.Application.Log.Warn().Int("status", statu).Msg(resp.ToJsonString())
|
that.reqLogger().Warn().Int("status", statu).Msg(resp.ToJsonString())
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
resp["result"] = data
|
resp["result"] = data
|
||||||
@@ -71,6 +72,33 @@ func (that *Context) Display(statu int, data interface{}) {
|
|||||||
//that.Data=d;
|
//that.Data=d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reqLogger 返回请求级 Logger,nil 时回退 Application.Log
|
||||||
|
func (that *Context) reqLogger() *htlog.Logger {
|
||||||
|
if that.Logger != nil {
|
||||||
|
return that.Logger
|
||||||
|
}
|
||||||
|
if that.Application != nil {
|
||||||
|
return that.Application.Log
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogBind 为本请求追加自定义日志字段(如 user_id),后续业务日志与 SQL 日志均携带。
|
||||||
|
// 供 main.go 的 connectListener 调用;nil 安全。
|
||||||
|
func (that *Context) LogBind(key string, value interface{}) {
|
||||||
|
if that == nil || key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := that.reqLogger()
|
||||||
|
if base == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
that.Logger = base.WithFields(key, ObjToStr(value))
|
||||||
|
if that.Db != nil {
|
||||||
|
that.Db.Log = that.Logger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (that *Context) View() {
|
func (that *Context) View() {
|
||||||
if that.RespFunc != nil {
|
if that.RespFunc != nil {
|
||||||
that.RespFunc()
|
that.RespFunc()
|
||||||
@@ -87,19 +115,7 @@ func (that *Context) View() {
|
|||||||
if that.Session("user_id").Data != nil {
|
if that.Session("user_id").Data != nil {
|
||||||
that.Log["user_id"] = that.Session("user_id").ToCeilInt()
|
that.Log["user_id"] = that.Session("user_id").ToCeilInt()
|
||||||
}
|
}
|
||||||
//负载均衡优化
|
that.Log["ip"] = clientIP(that.Req)
|
||||||
ipStr := ""
|
|
||||||
if that.Req.Header.Get("X-Forwarded-For") != "" {
|
|
||||||
ipStr = that.Req.Header.Get("X-Forwarded-For")
|
|
||||||
} else if that.Req.Header.Get("X-Real-IP") != "" {
|
|
||||||
ipStr = that.Req.Header.Get("X-Real-IP")
|
|
||||||
}
|
|
||||||
//负载均衡优化
|
|
||||||
if ipStr == "" {
|
|
||||||
//RemoteAddr := that.Req.RemoteAddr
|
|
||||||
ipStr = Substr(that.Req.RemoteAddr, 0, strings.Index(that.Req.RemoteAddr, ":"))
|
|
||||||
}
|
|
||||||
that.Log["ip"] = ipStr
|
|
||||||
that.Db.Insert("logs", that.Log)
|
that.Db.Insert("logs", that.Log)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+89
-95
@@ -19,7 +19,61 @@ HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seq
|
|||||||
| `seqUrl` | 空(不激活) | Seq 服务地址,空值时功能静默不生效 |
|
| `seqUrl` | 空(不激活) | Seq 服务地址,空值时功能静默不生效 |
|
||||||
| `seqApiKey` | 空 | API Key,免费单用户版留空 |
|
| `seqApiKey` | 空 | API Key,免费单用户版留空 |
|
||||||
|
|
||||||
`instance` 字段由框架自动拼接为 `ip:port`(如 `192.168.1.10:8085`),无需手动填写,同机多进程靠端口区分,跨服务器靠 IP 区分,支持未来集群扩展。
|
`instance` 字段由框架自动拼接为 `ip:port`(如 `192.168.1.10:8085`),无需手动填写。
|
||||||
|
|
||||||
|
激活后每个 HTTP 请求会自动:
|
||||||
|
|
||||||
|
- 生成 `request_id`(12 位 hex),回写响应头 `X-Request-Id`
|
||||||
|
- 将 `sessionId` 前 12 位作为 `sid` 写入请求级日志(脱敏,避免把登录凭据写进 Seq)
|
||||||
|
- 业务日志、SQL 日志、访问日志均携带 `sid` / `request_id`
|
||||||
|
- **控制台自动降噪为 Warn+**(Info/Debug/SQL/访问日志仍进 Seq 与文件,零额外配置)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 会话追踪与 LogBind
|
||||||
|
|
||||||
|
框架在 `handler` 入口派生请求级 Logger,并浅拷贝 `Db` 将其 `Log` 指向同一 Logger,因此 **SQL 日志自动带会话字段**。字段会出现在同条日志的控制台/文件/Seq 出口上。
|
||||||
|
|
||||||
|
业务在 `SetConnectListener` 鉴权通过后绑定(xbc `main.go`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// app 鉴权通过后
|
||||||
|
context.LogBind("user_id", context.Session("user_id").ToCeilInt64())
|
||||||
|
|
||||||
|
// admin 鉴权通过后(或 session 已有 admin_id)
|
||||||
|
context.LogBind("admin_id", context.Session("admin_id").ToCeilInt64())
|
||||||
|
```
|
||||||
|
|
||||||
|
`LogBind` 后,本请求后续业务日志与 SQL 日志都会带上该字段。
|
||||||
|
|
||||||
|
**不带会话字段的边界:**
|
||||||
|
|
||||||
|
- `fmt.Println` / stdout 捕获、panic、MySQL driver、定时任务等无请求上下文的日志
|
||||||
|
- 直接写 `that.Application.Log` 的旧代码(应改用 `that.Logger` 或 `that.LogBind` 后的请求级 Logger)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 控制台降噪(配了 seqUrl)
|
||||||
|
|
||||||
|
| 出口 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 控制台 | 仅 Warn / Error(含 `Display` 非 0 的 Warn) |
|
||||||
|
| Seq | 按 `logLevel` 全量(Info/Debug/SQL/访问日志等) |
|
||||||
|
| 本地文件 | 与原先一致,不受控制台过滤影响 |
|
||||||
|
|
||||||
|
未配置 `seqUrl` 时控制台仍按 `logLevel` 全打。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 客户端 IP 与地域
|
||||||
|
|
||||||
|
零配置,优先级:
|
||||||
|
|
||||||
|
1. `X-Real-IP`(EdgeOne / 反代透传的真实 IP)
|
||||||
|
2. `X-Forwarded-For` **第一段**
|
||||||
|
3. `RemoteAddr`
|
||||||
|
|
||||||
|
请求头有 `EO-Client-IPCountry` 时,访问日志追加 `ip_country`(两位国家码);没有则不记。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,26 +81,24 @@ HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seq
|
|||||||
|
|
||||||
```
|
```
|
||||||
业务代码
|
业务代码
|
||||||
│ l.Info().Msg("...") ← HoTime Logger 正常调用路径
|
│ that.Logger.Info().Msg("...") ← 请求级(含 sid/request_id)
|
||||||
│ fmt.Println("...") ← 被 redirectStdout 捕获后转入同一路径
|
│ Db.Query → SQL 日志 ← 同一请求级 Logger
|
||||||
|
│ fmt.Println("...") ← 捕获后无 sid
|
||||||
↓
|
↓
|
||||||
multiWriter(hotimev1.5/log/logger.go)
|
multiWriter(hotimev1.5/log/logger.go)
|
||||||
├─ ConsoleWriter → 彩色终端输出(不变)
|
├─ Console(挂 Seq 后 Warn+)→ 终端
|
||||||
├─ FileWriter → 本地日志文件(按需,logFile 配置)
|
├─ FileWriter → 本地文件(按需,全量)
|
||||||
└─ SeqWriter
|
└─ SeqWriter → Seq(全量)
|
||||||
│ Write() 只做 channel <- bytes,O(1) 非阻塞
|
│ Write() 只做 channel <- bytes,O(1) 非阻塞
|
||||||
↓
|
↓
|
||||||
channel(容量 10000)
|
channel(容量 10000)
|
||||||
↓ 后台 goroutine
|
↓ 后台 goroutine
|
||||||
批量打包(100 条 或 500ms)
|
批量打包(100 条 或 500ms)
|
||||||
↓ HTTP POST
|
↓ HTTP POST(失败重试 1 次)
|
||||||
Seq 服务(CLEF 格式)
|
Seq 服务(CLEF 格式)
|
||||||
```
|
```
|
||||||
|
|
||||||
**关键特性:**
|
优雅停机时调用 `CloseSeq()` 冲刷残留批次,避免停机前后日志丢失。
|
||||||
- `SeqWriter.Write()` 仅向 channel 投递字节即返回,**绝不阻塞** web 请求处理 goroutine
|
|
||||||
- channel 满时(Seq 宕机/网络故障)新日志被丢弃并计数,主服务完全不受影响
|
|
||||||
- HTTP POST 设 5s 超时,失败仅打印到 stderr
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -54,82 +106,16 @@ multiWriter(hotimev1.5/log/logger.go)
|
|||||||
|
|
||||||
| zerolog 字段 | Seq CLEF 字段 | 说明 |
|
| zerolog 字段 | Seq CLEF 字段 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `time` | `@t` | 时间,自动转 ISO 8601 格式 |
|
| `time` | `@t` | 毫秒精度 ISO 8601 |
|
||||||
| `level` | `@l` | 级别,映射为 Debug/Information/Warning/Error/Fatal |
|
| `level` | `@l` | Debug/Information/Warning/Error/Fatal |
|
||||||
| `message` / `msg` | `@mt` | 消息正文 |
|
| `message` / `msg` | `@m` | 消息正文(不用 `@mt`,避免 `{xxx}` 被当模板) |
|
||||||
| `caller` | `caller` | 调用位置,原样保留 |
|
| `caller` | `caller` | 调用位置 |
|
||||||
| 其余自定义字段 | 原字段名 | 原样保留,可在 Seq 中直接查询 |
|
| `sid` | `sid` | sessionId 前 12 位 |
|
||||||
| — | `instance` | 框架自动注入,值为 `port` 配置(如 `"8085"`) |
|
| `request_id` | `request_id` | 单次请求 id |
|
||||||
| — | `source` | fmt.Println 等捕获的输出标记为 `stdout` |
|
| `ip_country` | `ip_country` | 有 `EO-Client-IPCountry` 时 |
|
||||||
|
| 其余自定义字段 | 原字段名 | `LogBind` 追加的字段原样保留 |
|
||||||
---
|
| — | `instance` | `ip:port` |
|
||||||
|
| — | `source` | stdout / stderr / panic 等 |
|
||||||
## 单机多进程实例区分
|
|
||||||
|
|
||||||
框架启动时自动获取本机出口 IP,拼接为 `ip:port` 格式作为 `instance`:
|
|
||||||
|
|
||||||
```
|
|
||||||
单机多进程:
|
|
||||||
192.168.1.10:8085 ─┐
|
|
||||||
192.168.1.10:8086 ─┼─ HTTP CLEF ──→ Seq
|
|
||||||
192.168.1.10:8087 ─┘
|
|
||||||
|
|
||||||
多服务器集群:
|
|
||||||
192.168.1.10:8085 ─┐
|
|
||||||
192.168.1.11:8085 ─┼─ HTTP CLEF ──→ Seq(中央日志服务器)
|
|
||||||
192.168.1.12:8085 ─┘
|
|
||||||
```
|
|
||||||
|
|
||||||
Seq 中按实例筛选:
|
|
||||||
- 单台机器所有进程:`instance like '192.168.1.10%'`
|
|
||||||
- 精确到某个进程:`instance = '192.168.1.10:8085'`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## stdout / stderr 全量捕获
|
|
||||||
|
|
||||||
`SetConfig()` 中自动 `redirectStdout` + `redirectStderr`,并桥接 **logrus**(微信 SDK)到同一管道。
|
|
||||||
|
|
||||||
| 写法 | Seq 字段 | 说明 |
|
|
||||||
|------|----------|------|
|
|
||||||
| `fmt.Println` / `log.Println` | `source=stdout` | **不受 `logLevel` 影响**,`logLevel=0` 也会进 Seq |
|
|
||||||
| 写 `os.Stderr` 的包 | `source=stderr` | 同上,Error 级别 |
|
|
||||||
| `logrus.Info`(wechat) | `source=stdout` | redirect 后 `logrus.SetOutput` 桥接 |
|
|
||||||
| `that.Log.Info/Error...` | 结构化字段 | 经 `multiWriter` → SeqWriter |
|
|
||||||
| MySQL driver 内部错误 | `source=mysql-driver` | `SetLogger` 适配器 |
|
|
||||||
| 框架 `recover` 到的 panic | `source=panic` + `stack` | 代码层原因与调用栈 |
|
|
||||||
|
|
||||||
单行日志上限约 **10MB**(适配 `GetReqMap` 打整包 body);超长会截断并标注 `...(truncated)`。
|
|
||||||
|
|
||||||
**已知不进 Seq(文档边界):**
|
|
||||||
|
|
||||||
- 未 `recover`、进程直接崩溃的 runtime 栈(写 fd2,未做 Dup2)
|
|
||||||
- 达梦驱动自有文件日志(vendor 独立写盘)
|
|
||||||
- `seqUrl` 挂上之前的极早期引导日志
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 捕获矩阵速查
|
|
||||||
|
|
||||||
| 来源 | 进 Seq? | 检索示例 |
|
|
||||||
|------|----------|----------|
|
|
||||||
| `that.Log.*` | 是 | `@mt like '%关键词%'` |
|
|
||||||
| `log.Println` / `fmt.Println` | 是 | `source = 'stdout'` |
|
|
||||||
| logrus / 标准 `log` | 是 | `source = 'stdout'` |
|
|
||||||
| stderr 重定向 | 是 | `source = 'stderr'` |
|
|
||||||
| recover panic | 是 | `source = 'panic'` |
|
|
||||||
| MySQL driver | 是 | `source = 'mysql-driver'` |
|
|
||||||
| 队列满丢弃 | 否(计数) | 终端可见 `[seq] queue full` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Seq 安装
|
|
||||||
|
|
||||||
Seq 提供 Windows MSI 安装包和 Docker 镜像,单机免费,无外部数据库依赖:
|
|
||||||
|
|
||||||
- Windows:[https://datalust.co/download/seq](https://datalust.co/download/seq),安装后自动注册为 Windows 服务
|
|
||||||
- Docker:`docker run -d --restart always --name seq -p 5341:80 -e ACCEPT_EULA=Y datalust/seq`
|
|
||||||
- 访问 `http://localhost:5341` 使用 Web UI
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -137,15 +123,23 @@ Seq 提供 Windows MSI 安装包和 Docker 镜像,单机免费,无外部数
|
|||||||
|
|
||||||
| 目标 | 查询语句 |
|
| 目标 | 查询语句 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 关键词搜索 | 直接输入,如 `支付失败` |
|
| 按会话追踪 | `sid = 'abc123def456'` |
|
||||||
|
| 按单次请求 | `request_id = 'fedcba987654'` |
|
||||||
|
| 关键词 | 直接输入,如 `支付失败` |
|
||||||
| 日志级别 | `@l = 'Error'` |
|
| 日志级别 | `@l = 'Error'` |
|
||||||
| 特定实例 | `instance = '8085'` |
|
| 特定实例 | `instance = '192.168.1.10:8085'` |
|
||||||
| stdout 来源 | `source = 'stdout'` |
|
| 地域 | `ip_country = 'CN'` |
|
||||||
| panic 恢复 | `source = 'panic'` |
|
| stdout | `source = 'stdout'` |
|
||||||
| 请求体调试 | `请求参数GetReqMap` 或 `source = 'stdout'` |
|
| panic | `source = 'panic'` |
|
||||||
| 调用位置 | `caller like '%order.go%'` |
|
| 组合 | `@l = 'Error' and sid = 'abc123def456'` |
|
||||||
| 组合查询 | `@l = 'Error' and instance = '8086' and @mt like '%超时%'` |
|
|
||||||
| 日期范围 | 右上角时间选择器,支持精确到秒 |
|
---
|
||||||
|
|
||||||
|
## Seq 安装
|
||||||
|
|
||||||
|
- Windows:[https://datalust.co/download/seq](https://datalust.co/download/seq)
|
||||||
|
- Docker:`docker run -d --restart always --name seq -p 5341:80 -e ACCEPT_EULA=Y datalust/seq`
|
||||||
|
- 访问 `http://localhost:5341`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+178
-2
@@ -96,8 +96,8 @@ func TestToClef_PlainText(t *testing.T) {
|
|||||||
if err := json.Unmarshal(clef, &m); err != nil {
|
if err := json.Unmarshal(clef, &m); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if m["@mt"] != "plain log line" {
|
if m["@m"] != "plain log line" {
|
||||||
t.Fatalf("@mt=%v", m["@mt"])
|
t.Fatalf("@m=%v", m["@m"])
|
||||||
}
|
}
|
||||||
if m["source"] != "stdout" {
|
if m["source"] != "stdout" {
|
||||||
t.Fatalf("source=%v", m["source"])
|
t.Fatalf("source=%v", m["source"])
|
||||||
@@ -107,6 +107,182 @@ func TestToClef_PlainText(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWithFields_ChildHasFieldsParentClean(t *testing.T) {
|
||||||
|
parent, buf := newLoggerWithCapture(1)
|
||||||
|
child := parent.WithFields("sid", "abc123def456", "request_id", "fedcba987654")
|
||||||
|
child.Info().Msg("child-msg")
|
||||||
|
m := buf.lastJSON()
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("no output")
|
||||||
|
}
|
||||||
|
if m["sid"] != "abc123def456" || m["request_id"] != "fedcba987654" {
|
||||||
|
t.Fatalf("child fields missing: %v", m)
|
||||||
|
}
|
||||||
|
parent.Info().Msg("parent-msg")
|
||||||
|
m2 := buf.lastJSON()
|
||||||
|
if m2["sid"] != nil || m2["request_id"] != nil {
|
||||||
|
t.Fatalf("parent polluted: %v", m2)
|
||||||
|
}
|
||||||
|
// 错误历史父子共享
|
||||||
|
parent2 := NewLogger(1, "", 10)
|
||||||
|
child2 := parent2.WithFields("sid", "x")
|
||||||
|
child2.Error("shared-err")
|
||||||
|
if n := len(parent2.GetRecentErrors()); n != 1 {
|
||||||
|
t.Fatalf("shared error store want 1 got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToClef_MillisecondAndAtM(t *testing.T) {
|
||||||
|
sw := &SeqWriter{instance: "test:1"}
|
||||||
|
in := []byte(`{"level":"info","time":"2026-07-23 04:50:01.123","message":"hello {name}"}`)
|
||||||
|
clef := sw.toClef(in)
|
||||||
|
var m map[string]interface{}
|
||||||
|
if err := json.Unmarshal(clef, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m["@m"] != "hello {name}" {
|
||||||
|
t.Fatalf("@m=%v", m["@m"])
|
||||||
|
}
|
||||||
|
if m["@mt"] != nil {
|
||||||
|
t.Fatalf("should not have @mt: %v", m["@mt"])
|
||||||
|
}
|
||||||
|
at, ok := m["@t"].(string)
|
||||||
|
if !ok || !strings.Contains(at, ".123") {
|
||||||
|
t.Fatalf("@t missing ms: %v", m["@t"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeqWriter_RetryOnce(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
calls := 0
|
||||||
|
var gotBody string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
mu.Lock()
|
||||||
|
calls++
|
||||||
|
n := calls
|
||||||
|
if n == 1 {
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gotBody = string(body)
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
l := NewLogger(1, "", 0)
|
||||||
|
l.SetSeqWriter(srv.URL, "", "test:retry")
|
||||||
|
l.Info().Msg("retry-me")
|
||||||
|
|
||||||
|
deadline := time.Now().Add(4 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
mu.Lock()
|
||||||
|
ok := calls >= 2 && strings.Contains(gotBody, "retry-me")
|
||||||
|
mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
t.Fatalf("retry failed: calls=%d body=%q", calls, gotBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLevelFilterWriter_WarnOnly(t *testing.T) {
|
||||||
|
buf := &captureBuf{}
|
||||||
|
w := newLevelFilterWriter(buf, zerolog.WarnLevel)
|
||||||
|
mw := &multiWriter{writers: []io.Writer{w}}
|
||||||
|
zl := zerolog.New(mw).Level(zerolog.DebugLevel)
|
||||||
|
zl.Info().Msg("info-hidden")
|
||||||
|
zl.Warn().Msg("warn-shown")
|
||||||
|
buf.mu.Lock()
|
||||||
|
n := len(buf.lines)
|
||||||
|
buf.mu.Unlock()
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("want 1 console line got %d: %v", n, buf.lines)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.lines[0], "warn-shown") {
|
||||||
|
t.Fatalf("unexpected: %v", buf.lines[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetSeqWriter_QuietsConsoleKeepsSeqFull(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var gotBody string
|
||||||
|
srv := startFakeSeqServer(t, func(body string) {
|
||||||
|
mu.Lock()
|
||||||
|
gotBody += body
|
||||||
|
mu.Unlock()
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
l := NewLogger(1, "", 0)
|
||||||
|
consoleBuf := &captureBuf{}
|
||||||
|
l.console.inner = consoleBuf
|
||||||
|
l.SetSeqWriter(srv.URL, "", "test:quiet")
|
||||||
|
l.Info().Msg("info-to-seq-only")
|
||||||
|
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")
|
||||||
|
mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
body := gotBody
|
||||||
|
mu.Unlock()
|
||||||
|
if !strings.Contains(body, "info-to-seq-only") || !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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foundWarn := false
|
||||||
|
for _, line := range consoleBuf.lines {
|
||||||
|
if strings.Contains(line, "warn-both") {
|
||||||
|
foundWarn = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundWarn {
|
||||||
|
t.Fatalf("Warn missing on console: %v", consoleBuf.lines)
|
||||||
|
}
|
||||||
|
l.CloseSeq()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeqWriter_CloseFlushes(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var gotBody string
|
||||||
|
srv := startFakeSeqServer(t, func(body string) {
|
||||||
|
mu.Lock()
|
||||||
|
gotBody += body
|
||||||
|
mu.Unlock()
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
l := NewLogger(1, "", 0)
|
||||||
|
l.SetSeqWriter(srv.URL, "", "test:close")
|
||||||
|
l.Info().Msg("flush-on-close")
|
||||||
|
l.CloseSeq()
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if !strings.Contains(gotBody, "flush-on-close") {
|
||||||
|
t.Fatalf("Close did not flush: %q", gotBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStdoutCapture_LongLine(t *testing.T) {
|
func TestStdoutCapture_LongLine(t *testing.T) {
|
||||||
l, buf := newLoggerWithCapture(1)
|
l, buf := newLoggerWithCapture(1)
|
||||||
pr, pw := io.Pipe()
|
pr, pw := io.Pipe()
|
||||||
|
|||||||
+193
-62
@@ -47,18 +47,65 @@ type ErrorRecord struct {
|
|||||||
Caller string
|
Caller string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logger 日志核心结构体,封装 zerolog
|
// errorStore 错误历史环形缓冲(指针共享,供 WithFields 派生子 Logger 共用)
|
||||||
type Logger struct {
|
type errorStore struct {
|
||||||
zl zerolog.Logger
|
mu sync.RWMutex
|
||||||
mw *multiWriter // 动态多输出,支持运行时追加 SeqWriter 等
|
|
||||||
logLevel int
|
|
||||||
errors []ErrorRecord
|
errors []ErrorRecord
|
||||||
errIdx int // 环形缓冲写入位置
|
errIdx int
|
||||||
errCount int // 实际写入总数(用于判断缓冲是否满)
|
errCount int
|
||||||
errorsMu sync.RWMutex
|
|
||||||
maxErrors int
|
maxErrors int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logger 日志核心结构体,封装 zerolog
|
||||||
|
type Logger struct {
|
||||||
|
zl zerolog.Logger
|
||||||
|
mw *multiWriter // 动态多输出,支持运行时追加 SeqWriter 等
|
||||||
|
console *levelFilterWriter // 控制台出口,挂 Seq 后可降到 Warn+
|
||||||
|
logLevel int
|
||||||
|
store *errorStore
|
||||||
|
seqs []*SeqWriter // 本 Logger 挂载过的 SeqWriter,供 CloseSeq 冲刷
|
||||||
|
seqsMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// levelFilterWriter 按级别过滤的 Writer(实现 zerolog.LevelWriter)
|
||||||
|
type levelFilterWriter struct {
|
||||||
|
inner io.Writer
|
||||||
|
minLevel atomic.Int32 // zerolog.Level 存为 int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLevelFilterWriter(inner io.Writer, min zerolog.Level) *levelFilterWriter {
|
||||||
|
w := &levelFilterWriter{inner: inner}
|
||||||
|
w.minLevel.Store(int32(min))
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *levelFilterWriter) setMinLevel(min zerolog.Level) {
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.minLevel.Store(int32(min))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *levelFilterWriter) Write(p []byte) (int, error) {
|
||||||
|
// 无级别信息时按 Info 处理:低于门槛则丢弃
|
||||||
|
if zerolog.Level(w.minLevel.Load()) > zerolog.InfoLevel {
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
return w.inner.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *levelFilterWriter) WriteLevel(l zerolog.Level, p []byte) (int, error) {
|
||||||
|
if l < zerolog.Level(w.minLevel.Load()) {
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
if lw, ok := w.inner.(zerolog.LevelWriter); ok {
|
||||||
|
return lw.WriteLevel(l, p)
|
||||||
|
}
|
||||||
|
return w.inner.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeFormatMS = "2006-01-02 15:04:05.000"
|
||||||
|
|
||||||
// NewLogger 创建日志实例
|
// NewLogger 创建日志实例
|
||||||
// logLevel: 0=仅 error,>=1=全部
|
// logLevel: 0=仅 error,>=1=全部
|
||||||
// logFile: 文件路径模板(如 "logs/20060102.txt"),空则不写文件
|
// logFile: 文件路径模板(如 "logs/20060102.txt"),空则不写文件
|
||||||
@@ -66,7 +113,7 @@ type Logger struct {
|
|||||||
func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
|
func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
|
||||||
saveOrigStderr()
|
saveOrigStderr()
|
||||||
zerolog.CallerMarshalFunc = callerMarshalFunc
|
zerolog.CallerMarshalFunc = callerMarshalFunc
|
||||||
zerolog.TimeFieldFormat = "2006-01-02 15:04:05"
|
zerolog.TimeFieldFormat = timeFormatMS
|
||||||
|
|
||||||
var level zerolog.Level
|
var level zerolog.Level
|
||||||
if logLevel == 0 {
|
if logLevel == 0 {
|
||||||
@@ -77,7 +124,7 @@ func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
|
|||||||
|
|
||||||
consoleWriter := zerolog.ConsoleWriter{
|
consoleWriter := zerolog.ConsoleWriter{
|
||||||
Out: os.Stderr,
|
Out: os.Stderr,
|
||||||
TimeFormat: "2006-01-02 15:04:05",
|
TimeFormat: timeFormatMS,
|
||||||
NoColor: false,
|
NoColor: false,
|
||||||
FormatLevel: formatLevelColor,
|
FormatLevel: formatLevelColor,
|
||||||
FormatCaller: func(i interface{}) string {
|
FormatCaller: func(i interface{}) string {
|
||||||
@@ -87,9 +134,10 @@ func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
|
|||||||
return fmt.Sprintf("[%s]", i)
|
return fmt.Sprintf("[%s]", i)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
console := newLevelFilterWriter(consoleWriter, zerolog.DebugLevel)
|
||||||
|
|
||||||
var writers []io.Writer
|
var writers []io.Writer
|
||||||
writers = append(writers, consoleWriter)
|
writers = append(writers, console)
|
||||||
|
|
||||||
if logFile != "" {
|
if logFile != "" {
|
||||||
fw := &TemplateFileWriter{pathTemplate: logFile}
|
fw := &TemplateFileWriter{pathTemplate: logFile}
|
||||||
@@ -105,13 +153,14 @@ func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
|
|||||||
Logger()
|
Logger()
|
||||||
|
|
||||||
l := &Logger{
|
l := &Logger{
|
||||||
zl: zl,
|
zl: zl,
|
||||||
mw: mw,
|
mw: mw,
|
||||||
logLevel: logLevel,
|
console: console,
|
||||||
maxErrors: maxErrors,
|
logLevel: logLevel,
|
||||||
|
store: &errorStore{maxErrors: maxErrors},
|
||||||
}
|
}
|
||||||
if maxErrors > 0 {
|
if maxErrors > 0 {
|
||||||
l.errors = make([]ErrorRecord, maxErrors)
|
l.store.errors = make([]ErrorRecord, maxErrors)
|
||||||
}
|
}
|
||||||
|
|
||||||
return l
|
return l
|
||||||
@@ -121,7 +170,7 @@ func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
|
|||||||
func NewLoggerNoCaller(logLevel int, logFile string, maxErrors int) *Logger {
|
func NewLoggerNoCaller(logLevel int, logFile string, maxErrors int) *Logger {
|
||||||
saveOrigStderr()
|
saveOrigStderr()
|
||||||
zerolog.CallerMarshalFunc = callerMarshalFunc
|
zerolog.CallerMarshalFunc = callerMarshalFunc
|
||||||
zerolog.TimeFieldFormat = "2006-01-02 15:04:05"
|
zerolog.TimeFieldFormat = timeFormatMS
|
||||||
|
|
||||||
var level zerolog.Level
|
var level zerolog.Level
|
||||||
if logLevel == 0 {
|
if logLevel == 0 {
|
||||||
@@ -132,13 +181,14 @@ func NewLoggerNoCaller(logLevel int, logFile string, maxErrors int) *Logger {
|
|||||||
|
|
||||||
consoleWriter := zerolog.ConsoleWriter{
|
consoleWriter := zerolog.ConsoleWriter{
|
||||||
Out: os.Stderr,
|
Out: os.Stderr,
|
||||||
TimeFormat: "2006-01-02 15:04:05",
|
TimeFormat: timeFormatMS,
|
||||||
NoColor: false,
|
NoColor: false,
|
||||||
FormatLevel: formatLevelColor,
|
FormatLevel: formatLevelColor,
|
||||||
}
|
}
|
||||||
|
console := newLevelFilterWriter(consoleWriter, zerolog.DebugLevel)
|
||||||
|
|
||||||
var writers []io.Writer
|
var writers []io.Writer
|
||||||
writers = append(writers, consoleWriter)
|
writers = append(writers, console)
|
||||||
|
|
||||||
if logFile != "" {
|
if logFile != "" {
|
||||||
fw := &TemplateFileWriter{pathTemplate: logFile}
|
fw := &TemplateFileWriter{pathTemplate: logFile}
|
||||||
@@ -153,18 +203,40 @@ func NewLoggerNoCaller(logLevel int, logFile string, maxErrors int) *Logger {
|
|||||||
Logger()
|
Logger()
|
||||||
|
|
||||||
l := &Logger{
|
l := &Logger{
|
||||||
zl: zl,
|
zl: zl,
|
||||||
mw: mw,
|
mw: mw,
|
||||||
logLevel: logLevel,
|
console: console,
|
||||||
maxErrors: maxErrors,
|
logLevel: logLevel,
|
||||||
|
store: &errorStore{maxErrors: maxErrors},
|
||||||
}
|
}
|
||||||
if maxErrors > 0 {
|
if maxErrors > 0 {
|
||||||
l.errors = make([]ErrorRecord, maxErrors)
|
l.store.errors = make([]ErrorRecord, maxErrors)
|
||||||
}
|
}
|
||||||
|
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithFields 派生子 Logger,附加成对 key,value 字段;mw/错误历史与父共享,父不受污染。
|
||||||
|
func (l *Logger) WithFields(kvs ...string) *Logger {
|
||||||
|
if l == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx := l.zl.With()
|
||||||
|
for i := 0; i+1 < len(kvs); i += 2 {
|
||||||
|
if kvs[i] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ctx = ctx.Str(kvs[i], kvs[i+1])
|
||||||
|
}
|
||||||
|
return &Logger{
|
||||||
|
zl: ctx.Logger(),
|
||||||
|
mw: l.mw,
|
||||||
|
console: l.console,
|
||||||
|
logLevel: l.logLevel,
|
||||||
|
store: l.store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewTestLogger 创建用于测试的静默 Logger(输出到 io.Discard)
|
// NewTestLogger 创建用于测试的静默 Logger(输出到 io.Discard)
|
||||||
func NewTestLogger() *Logger {
|
func NewTestLogger() *Logger {
|
||||||
zl := zerolog.New(io.Discard).Level(zerolog.Disabled)
|
zl := zerolog.New(io.Discard).Level(zerolog.Disabled)
|
||||||
@@ -180,7 +252,7 @@ func (l *Logger) SetOutput(w io.Writer) {
|
|||||||
}
|
}
|
||||||
consoleWriter := zerolog.ConsoleWriter{
|
consoleWriter := zerolog.ConsoleWriter{
|
||||||
Out: w,
|
Out: w,
|
||||||
TimeFormat: "2006-01-02 15:04:05",
|
TimeFormat: timeFormatMS,
|
||||||
NoColor: true,
|
NoColor: true,
|
||||||
FormatLevel: formatLevelPlain,
|
FormatLevel: formatLevelPlain,
|
||||||
FormatCaller: func(i interface{}) string {
|
FormatCaller: func(i interface{}) string {
|
||||||
@@ -295,14 +367,14 @@ func (l *Logger) Warn(args ...interface{}) *zerolog.Event {
|
|||||||
func (l *Logger) Error(args ...interface{}) *zerolog.Event {
|
func (l *Logger) Error(args ...interface{}) *zerolog.Event {
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
msg := fmt.Sprint(args...)
|
msg := fmt.Sprint(args...)
|
||||||
if l.maxErrors > 0 {
|
if l.store != nil && l.store.maxErrors > 0 {
|
||||||
l.recordError(msg, nil)
|
l.recordError(msg, nil)
|
||||||
}
|
}
|
||||||
l.zl.Error().Msg(msg)
|
l.zl.Error().Msg(msg)
|
||||||
nop := zerolog.Nop()
|
nop := zerolog.Nop()
|
||||||
return nop.Error()
|
return nop.Error()
|
||||||
}
|
}
|
||||||
if l.maxErrors > 0 {
|
if l.store != nil && l.store.maxErrors > 0 {
|
||||||
l.recordError("", nil)
|
l.recordError("", nil)
|
||||||
}
|
}
|
||||||
return l.zl.Error()
|
return l.zl.Error()
|
||||||
@@ -310,19 +382,20 @@ func (l *Logger) Error(args ...interface{}) *zerolog.Event {
|
|||||||
|
|
||||||
// RecordError 手动记录一条错误到历史(用于需要指定详情的场景)
|
// RecordError 手动记录一条错误到历史(用于需要指定详情的场景)
|
||||||
func (l *Logger) RecordError(err error, msg string, caller string) {
|
func (l *Logger) RecordError(err error, msg string, caller string) {
|
||||||
if l.maxErrors <= 0 {
|
if l == nil || l.store == nil || l.store.maxErrors <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
l.errorsMu.Lock()
|
s := l.store
|
||||||
defer l.errorsMu.Unlock()
|
s.mu.Lock()
|
||||||
l.errors[l.errIdx] = ErrorRecord{
|
defer s.mu.Unlock()
|
||||||
|
s.errors[s.errIdx] = ErrorRecord{
|
||||||
Err: err,
|
Err: err,
|
||||||
Msg: msg,
|
Msg: msg,
|
||||||
Time: time.Now(),
|
Time: time.Now(),
|
||||||
Caller: caller,
|
Caller: caller,
|
||||||
}
|
}
|
||||||
l.errIdx = (l.errIdx + 1) % l.maxErrors
|
s.errIdx = (s.errIdx + 1) % s.maxErrors
|
||||||
l.errCount++
|
s.errCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) recordError(msg string, err error) {
|
func (l *Logger) recordError(msg string, err error) {
|
||||||
@@ -336,15 +409,16 @@ func (l *Logger) recordError(msg string, err error) {
|
|||||||
|
|
||||||
// GetRecentErrors 获取最近 N 条错误(不传则返回全部已存储的)
|
// GetRecentErrors 获取最近 N 条错误(不传则返回全部已存储的)
|
||||||
func (l *Logger) GetRecentErrors(n ...int) []ErrorRecord {
|
func (l *Logger) GetRecentErrors(n ...int) []ErrorRecord {
|
||||||
if l.maxErrors <= 0 {
|
if l == nil || l.store == nil || l.store.maxErrors <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
l.errorsMu.RLock()
|
s := l.store
|
||||||
defer l.errorsMu.RUnlock()
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
total := l.errCount
|
total := s.errCount
|
||||||
if total > l.maxErrors {
|
if total > s.maxErrors {
|
||||||
total = l.maxErrors
|
total = s.maxErrors
|
||||||
}
|
}
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -356,13 +430,12 @@ func (l *Logger) GetRecentErrors(n ...int) []ErrorRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result := make([]ErrorRecord, 0, want)
|
result := make([]ErrorRecord, 0, want)
|
||||||
// 从最新的往前读
|
|
||||||
for i := 0; i < want; i++ {
|
for i := 0; i < want; i++ {
|
||||||
idx := (l.errIdx - 1 - i + l.maxErrors) % l.maxErrors
|
idx := (s.errIdx - 1 - i + s.maxErrors) % s.maxErrors
|
||||||
if l.errors[idx].Time.IsZero() {
|
if s.errors[idx].Time.IsZero() {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
result = append(result, l.errors[idx])
|
result = append(result, s.errors[idx])
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -379,7 +452,7 @@ func (l *Logger) Warnf(format string, v ...interface{}) {
|
|||||||
l.zl.Warn().Msgf(format, v...)
|
l.zl.Warn().Msgf(format, v...)
|
||||||
}
|
}
|
||||||
func (l *Logger) Errorf(format string, v ...interface{}) {
|
func (l *Logger) Errorf(format string, v ...interface{}) {
|
||||||
if l.maxErrors > 0 {
|
if l.store != nil && l.store.maxErrors > 0 {
|
||||||
l.recordError(fmt.Sprintf(format, v...), nil)
|
l.recordError(fmt.Sprintf(format, v...), nil)
|
||||||
}
|
}
|
||||||
l.zl.Error().Msgf(format, v...)
|
l.zl.Error().Msgf(format, v...)
|
||||||
@@ -560,6 +633,8 @@ type SeqWriter struct {
|
|||||||
queue chan []byte
|
queue chan []byte
|
||||||
dropped int64
|
dropped int64
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
closed int32
|
||||||
|
done chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSeqWriter(seqUrl, apiKey, instance string) *SeqWriter {
|
func newSeqWriter(seqUrl, apiKey, instance string) *SeqWriter {
|
||||||
@@ -569,6 +644,7 @@ func newSeqWriter(seqUrl, apiKey, instance string) *SeqWriter {
|
|||||||
instance: instance,
|
instance: instance,
|
||||||
queue: make(chan []byte, 10000),
|
queue: make(chan []byte, 10000),
|
||||||
client: &http.Client{Timeout: 5 * time.Second},
|
client: &http.Client{Timeout: 5 * time.Second},
|
||||||
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
go w.run()
|
go w.run()
|
||||||
return w
|
return w
|
||||||
@@ -576,6 +652,9 @@ func newSeqWriter(seqUrl, apiKey, instance string) *SeqWriter {
|
|||||||
|
|
||||||
// Write 将字节塞入 channel 即返回,由后台 goroutine 消费发送
|
// Write 将字节塞入 channel 即返回,由后台 goroutine 消费发送
|
||||||
func (w *SeqWriter) Write(p []byte) (int, error) {
|
func (w *SeqWriter) Write(p []byte) (int, error) {
|
||||||
|
if atomic.LoadInt32(&w.closed) != 0 {
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
clef := w.toClef(p)
|
clef := w.toClef(p)
|
||||||
select {
|
select {
|
||||||
case w.queue <- clef:
|
case w.queue <- clef:
|
||||||
@@ -588,15 +667,31 @@ func (w *SeqWriter) Write(p []byte) (int, error) {
|
|||||||
return len(p), nil
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close 停止接收新条目,并在约 2s 内冲刷残留批次(幂等)
|
||||||
|
func (w *SeqWriter) Close() {
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !atomic.CompareAndSwapInt32(&w.closed, 0, 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
close(w.queue)
|
||||||
|
select {
|
||||||
|
case <-w.done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
seqWriteStderr("[seq] Close timeout, some events may be dropped\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// toClef 将 zerolog JSON 字段映射到 Seq CLEF 格式
|
// toClef 将 zerolog JSON 字段映射到 Seq CLEF 格式
|
||||||
// zerolog: time/level/message(msg) → CLEF: @t/@l/@mt
|
// zerolog: time/level/message(msg) → CLEF: @t/@l/@m
|
||||||
func (w *SeqWriter) toClef(p []byte) []byte {
|
func (w *SeqWriter) toClef(p []byte) []byte {
|
||||||
trimmed := bytes.TrimSpace(p)
|
trimmed := bytes.TrimSpace(p)
|
||||||
var m map[string]interface{}
|
var m map[string]interface{}
|
||||||
if err := json.Unmarshal(trimmed, &m); err != nil {
|
if err := json.Unmarshal(trimmed, &m); err != nil {
|
||||||
// 非 JSON(如 fmt.Println 直接输出),包装为纯文本日志
|
// 非 JSON(如 fmt.Println 直接输出),包装为纯文本日志
|
||||||
return []byte(fmt.Sprintf(`{"@t":%q,"@l":"Information","@mt":%q,"source":"stdout","instance":%q}`,
|
return []byte(fmt.Sprintf(`{"@t":%q,"@l":"Information","@m":%q,"source":"stdout","instance":%q}`,
|
||||||
time.Now().Format(time.RFC3339), strings.TrimSpace(string(p)), w.instance))
|
time.Now().Format("2006-01-02T15:04:05.000Z07:00"), strings.TrimSpace(string(p)), w.instance))
|
||||||
}
|
}
|
||||||
|
|
||||||
clef := make(map[string]interface{}, len(m)+3)
|
clef := make(map[string]interface{}, len(m)+3)
|
||||||
@@ -604,16 +699,18 @@ func (w *SeqWriter) toClef(p []byte) []byte {
|
|||||||
clef[k] = v
|
clef[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
// time → @t(zerolog 格式 "2006-01-02 15:04:05" → ISO 8601)
|
// time → @t(优先毫秒格式,回退秒级)
|
||||||
if t, ok := m["time"].(string); ok {
|
if t, ok := m["time"].(string); ok {
|
||||||
delete(clef, "time")
|
delete(clef, "time")
|
||||||
if pt, err := time.ParseInLocation("2006-01-02 15:04:05", t, time.Local); err == nil {
|
if pt, err := time.ParseInLocation(timeFormatMS, t, time.Local); err == nil {
|
||||||
clef["@t"] = pt.Format(time.RFC3339)
|
clef["@t"] = pt.Format("2006-01-02T15:04:05.000Z07:00")
|
||||||
|
} else if pt, err := time.ParseInLocation("2006-01-02 15:04:05", t, time.Local); err == nil {
|
||||||
|
clef["@t"] = pt.Format("2006-01-02T15:04:05.000Z07:00")
|
||||||
} else {
|
} else {
|
||||||
clef["@t"] = t
|
clef["@t"] = t
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
clef["@t"] = time.Now().Format(time.RFC3339)
|
clef["@t"] = time.Now().Format("2006-01-02T15:04:05.000Z07:00")
|
||||||
}
|
}
|
||||||
|
|
||||||
// level → @l
|
// level → @l
|
||||||
@@ -635,13 +732,13 @@ func (w *SeqWriter) toClef(p []byte) []byte {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// message/msg → @mt
|
// message/msg → @m(不用 @mt,避免 Seq 把 {xxx} 当消息模板)
|
||||||
if msg, ok := m["message"].(string); ok {
|
if msg, ok := m["message"].(string); ok {
|
||||||
delete(clef, "message")
|
delete(clef, "message")
|
||||||
clef["@mt"] = msg
|
clef["@m"] = msg
|
||||||
} else if msg, ok := m["msg"].(string); ok {
|
} else if msg, ok := m["msg"].(string); ok {
|
||||||
delete(clef, "msg")
|
delete(clef, "msg")
|
||||||
clef["@mt"] = msg
|
clef["@m"] = msg
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.instance != "" {
|
if w.instance != "" {
|
||||||
@@ -652,14 +749,21 @@ func (w *SeqWriter) toClef(p []byte) []byte {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// run 后台 goroutine:每 100 条或 500ms 批量 POST 到 Seq
|
// run 后台 goroutine:每 100 条或 500ms 批量 POST 到 Seq;queue 关闭后冲刷并退出
|
||||||
func (w *SeqWriter) run() {
|
func (w *SeqWriter) run() {
|
||||||
|
defer close(w.done)
|
||||||
batch := make([][]byte, 0, 100)
|
batch := make([][]byte, 0, 100)
|
||||||
ticker := time.NewTicker(500 * time.Millisecond)
|
ticker := time.NewTicker(500 * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case entry := <-w.queue:
|
case entry, ok := <-w.queue:
|
||||||
|
if !ok {
|
||||||
|
if len(batch) > 0 {
|
||||||
|
w.flush(batch)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
batch = append(batch, entry)
|
batch = append(batch, entry)
|
||||||
if len(batch) >= 100 {
|
if len(batch) >= 100 {
|
||||||
w.flush(batch)
|
w.flush(batch)
|
||||||
@@ -675,6 +779,15 @@ func (w *SeqWriter) run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *SeqWriter) flush(batch [][]byte) {
|
func (w *SeqWriter) flush(batch [][]byte) {
|
||||||
|
if err := w.postBatch(batch); err != nil {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
if err2 := w.postBatch(batch); err2 != nil {
|
||||||
|
seqWriteStderr("[seq] send failed after retry: %v (dropped=%d)\n", err2, atomic.LoadInt64(&w.dropped))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *SeqWriter) postBatch(batch [][]byte) error {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
for _, entry := range batch {
|
for _, entry := range batch {
|
||||||
buf.Write(entry)
|
buf.Write(entry)
|
||||||
@@ -682,8 +795,7 @@ func (w *SeqWriter) flush(batch [][]byte) {
|
|||||||
}
|
}
|
||||||
req, err := http.NewRequest("POST", w.seqUrl+"/api/events/raw?clef", &buf)
|
req, err := http.NewRequest("POST", w.seqUrl+"/api/events/raw?clef", &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
seqWriteStderr("[seq] build request error: %v\n", err)
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/vnd.serilog.clef")
|
req.Header.Set("Content-Type", "application/vnd.serilog.clef")
|
||||||
if w.apiKey != "" {
|
if w.apiKey != "" {
|
||||||
@@ -691,14 +803,14 @@ func (w *SeqWriter) flush(batch [][]byte) {
|
|||||||
}
|
}
|
||||||
resp, err := w.client.Do(req)
|
resp, err := w.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
seqWriteStderr("[seq] send error: %v (dropped=%d)\n", err, atomic.LoadInt64(&w.dropped))
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
seqWriteStderr("[seq] server returned %d: %s\n", resp.StatusCode, string(body))
|
return fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSeqWriter 将 Seq HTTP 推送器附加到当前 Logger(不影响已有的控制台/文件输出)
|
// SetSeqWriter 将 Seq HTTP 推送器附加到当前 Logger(不影响已有的控制台/文件输出)
|
||||||
@@ -706,11 +818,30 @@ func (w *SeqWriter) flush(batch [][]byte) {
|
|||||||
// apiKey: Seq API Key,免费单用户版留空
|
// apiKey: Seq API Key,免费单用户版留空
|
||||||
// instance: 实例标识,建议用 config 的 port 字段区分同机多进程,如 "8085"
|
// instance: 实例标识,建议用 config 的 port 字段区分同机多进程,如 "8085"
|
||||||
func (l *Logger) SetSeqWriter(seqUrl, apiKey, instance string) {
|
func (l *Logger) SetSeqWriter(seqUrl, apiKey, instance string) {
|
||||||
if seqUrl == "" || l.mw == nil {
|
if l == nil || seqUrl == "" || l.mw == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sw := newSeqWriter(seqUrl, apiKey, instance)
|
sw := newSeqWriter(seqUrl, apiKey, instance)
|
||||||
l.mw.add(sw)
|
l.mw.add(sw)
|
||||||
|
l.seqsMu.Lock()
|
||||||
|
l.seqs = append(l.seqs, sw)
|
||||||
|
l.seqsMu.Unlock()
|
||||||
|
// 挂上 Seq 后控制台只打 Warn+,Info/Debug/SQL/访问日志仍进 Seq 与文件
|
||||||
|
l.console.setMinLevel(zerolog.WarnLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseSeq 冲刷并关闭本 Logger 挂载的全部 SeqWriter(幂等、nil 安全)
|
||||||
|
func (l *Logger) CloseSeq() {
|
||||||
|
if l == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.seqsMu.Lock()
|
||||||
|
seqs := l.seqs
|
||||||
|
l.seqs = nil
|
||||||
|
l.seqsMu.Unlock()
|
||||||
|
for _, sw := range seqs {
|
||||||
|
sw.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isInfrastructureFile 判断文件是否为基础设施(日志/DB/缓存/通用工具)管线,
|
// isInfrastructureFile 判断文件是否为基础设施(日志/DB/缓存/通用工具)管线,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ var ConfigNote = Map{
|
|||||||
"logHistory": "默认100,非必须,内存中保留最近N条错误日志,用于调试调阅,通过 Log.GetRecentErrors() 获取",
|
"logHistory": "默认100,非必须,内存中保留最近N条错误日志,用于调试调阅,通过 Log.GetRecentErrors() 获取",
|
||||||
"webConnectLogShow": "默认true,非必须,访问日志如果需要web访问链接、访问ip、访问时间打印,false为关闭true开启此功能",
|
"webConnectLogShow": "默认true,非必须,访问日志如果需要web访问链接、访问ip、访问时间打印,false为关闭true开启此功能",
|
||||||
"webConnectLogFile": "无默认,非必须,webConnectLogShow开启之后才能使用,如果需要存储日志文件时使用,保存格式为:a/b/c/20060102150405.txt,将生成:a/b/c/年月日时分秒.txt,按需设置",
|
"webConnectLogFile": "无默认,非必须,webConnectLogShow开启之后才能使用,如果需要存储日志文件时使用,保存格式为:a/b/c/20060102150405.txt,将生成:a/b/c/年月日时分秒.txt,按需设置",
|
||||||
"seqUrl": "无默认,非必须,Seq 日志平台地址,如 http://127.0.0.1:5341,填写后自动将所有日志通过异步 channel 队列推送到 Seq,空值则不激活;同时自动将 fmt.Println/标准log 包的输出也一并捕获推送;instance 字段自动拼接为 ip:port(如 192.168.1.10:8085),支持单机多进程和多服务器集群",
|
"seqUrl": "无默认,非必须,Seq 日志平台地址,如 http://127.0.0.1:5341,填写后自动将所有日志通过异步 channel 队列推送到 Seq,空值则不激活;同时自动将 fmt.Println/标准log 包的输出也一并捕获推送;instance 字段自动拼接为 ip:port;请求日志自动带 sid(sessionId 前12位脱敏)与 request_id(并回写 X-Request-Id),可用 that.LogBind 追加 user_id 等;访问日志优先取 X-Real-IP,有 EO-Client-IPCountry 时记 ip_country;配置后控制台自动仅打 Warn/Error,Seq 与文件仍按 logLevel 全量",
|
||||||
"seqApiKey": "无默认,非必须,Seq API Key,单机免费版留空即可,多用户或有认证要求时填写",
|
"seqApiKey": "无默认,非必须,Seq API Key,单机免费版留空即可,多用户或有认证要求时填写",
|
||||||
//"codeConfig": Map{
|
//"codeConfig": Map{
|
||||||
// "注释": "配置即启用,非必须,默认无",
|
// "注释": "配置即启用,非必须,默认无",
|
||||||
|
|||||||
Reference in New Issue
Block a user