feat: add precise diagnostics and optional client copy logging controls
This commit is contained in:
@@ -14,6 +14,20 @@ logger.InfoContext(ctx, "invoice.saved", "invoiceId", invoiceID)
|
||||
|
||||
`Debug/Info/Warn/Error` 有包级和实例方法,也有带 Context 的方法。`requestId` 自动跟随 HTTP Context;接入 telemetry 后可带 `traceId`。不要在 Service 中改用 `context.Background()`,否则会丢失取消信号和关联字段。
|
||||
|
||||
## 实例时区
|
||||
|
||||
```yaml
|
||||
server:
|
||||
log:
|
||||
timeZone: Asia/Shanghai
|
||||
```
|
||||
|
||||
手写项目使用 `logx.New(logx.Config{TimeZone: "Asia/Shanghai"})`。配置名称采用 IANA 名称或 UTC;空字符串保持已有时间戳行为。无效名称在打开文件之前报 `timeZone` 的安全错误;统一加载后路径为 `server.log.timeZone`。
|
||||
|
||||
时区在创建 Logger 时解析,记录输出不重复加载;JSON/text、With/WithGroup 子日志和 Standard 适配器均继承该实例时区。不同 Logger 可同时使用不同地区,不修改全局 time.Local。仅转换记录时间戳,业务 `time.Time` 属性保留原时区;夏令时由时区数据处理。时区变更需要重建 Logger,现有热加载仍只支持级别。
|
||||
|
||||
使用 Go 标准库查找时区。极简镜像需提供时区数据,或执行 `go build -tags timetzdata .` 将标准库时区数据内置(增加约 450 KB);也可由手写 main 导入 time/tzdata。logx 自身不强制内置数据,不引入第三方依赖。脱敏、日志级别和文件所有权语义保持不变。可运行例子:[developerTools](../examples/developerTools/README.md)。
|
||||
|
||||
## 文件轮转
|
||||
|
||||
```go
|
||||
@@ -53,7 +67,7 @@ application := omni.New(
|
||||
)
|
||||
```
|
||||
|
||||
`WithSlowRequestThreshold` 同时开启访问日志,0 禁用慢请求识别。也可以在 JSON/YAML 中配 `enableLogger: true`、`slowRequestThreshold: "500ms"`,或使用 `GO_OMNI_SLOW_REQUEST_THRESHOLD` 配合 `GO_OMNI_ENABLE_LOGGER=true`。
|
||||
`WithSlowRequestThreshold` 同时开启访问日志,0 禁用慢请求识别。也可以在配置文件的 server 下配 `enableLogger: true`、`slowRequestThreshold: "500ms"`。需要环境变量控制时,显式写 `slowRequestThreshold: "${SLOW_REQUEST_THRESHOLD:-500ms}"`;没有文件引用时不会读取同名环境变量。
|
||||
|
||||
每个请求记录一条 `http.request`:方法、路径、匹配路由、实际状态码、耗时、客户端 IP 和关联字段。正常请求 INFO,超过阈值 WARN(`slow: true`),5xx ERROR;返回的 error 会记录到结构化字段。计时覆盖 Handler 调用,不包含框架之外的代理排队,也不自动计入 Handler 返回后继续写入的自管流。
|
||||
|
||||
|
||||
@@ -2,27 +2,33 @@ package logx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
|
||||
)
|
||||
|
||||
// Validate checks configuration without opening files or creating resources.
|
||||
func (c Config) Validate() error {
|
||||
func (c Config) Validate() error { _, err := c.validate(); return err }
|
||||
func (c Config) validate() (*time.Location, error) {
|
||||
c = normalizeConfig(c)
|
||||
if _, err := parseLevel(c.Level); err != nil {
|
||||
return errors.New("logx: invalid level")
|
||||
return nil, configdiag.New("level", "must be debug, info, warn, warning or error")
|
||||
}
|
||||
if c.Format != "json" && c.Format != "text" {
|
||||
return errors.New("logx: format must be json or text")
|
||||
return nil, configdiag.New("format", "must be json or text")
|
||||
}
|
||||
switch c.Output {
|
||||
case "stdout", "stderr", "discard", "none":
|
||||
default:
|
||||
return errors.New("logx: invalid output")
|
||||
return nil, configdiag.New("output", "must be stdout, stderr, discard or none")
|
||||
}
|
||||
return c.Rotation.validate(c)
|
||||
if err := c.Rotation.validate(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return logLocation(c.TimeZone)
|
||||
}
|
||||
|
||||
type loggerContextKey struct{}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
// standard stream. File takes precedence over Output and appends to the named
|
||||
// file.
|
||||
type Config struct {
|
||||
TimeZone string `json:"timeZone"` // Empty retains existing timestamp behavior.
|
||||
Level string `json:"level"`
|
||||
Format string `json:"format"`
|
||||
Output string `json:"output"`
|
||||
@@ -73,7 +74,8 @@ func init() {
|
||||
// call Close when the process is shutting down.
|
||||
func New(cfg Config) (*Logger, error) {
|
||||
cfg = normalizeConfig(cfg)
|
||||
if err := cfg.Validate(); err != nil {
|
||||
location, err := cfg.validate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
level, err := parseLevel(cfg.Level)
|
||||
@@ -95,6 +97,9 @@ func New(cfg Config) (*Logger, error) {
|
||||
} else {
|
||||
handler = slog.NewJSONHandler(writer, opts)
|
||||
}
|
||||
if location != nil {
|
||||
handler = zoneHandler{Handler: handler, location: location}
|
||||
}
|
||||
logger := slog.New(handler)
|
||||
if cfg.Service != "" {
|
||||
logger = logger.With("service", cfg.Service)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package logx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
|
||||
)
|
||||
|
||||
// RotationConfig opts a file logger into size-based rotation. Zero limits use
|
||||
@@ -21,12 +23,21 @@ type RotationConfig struct {
|
||||
}
|
||||
|
||||
func (r RotationConfig) validate(cfg Config) error {
|
||||
if r.MaxSizeMB < 0 || r.MaxSizeMB > 1024*1024 || r.MaxBackups < 0 || r.MaxBackups > 10000 || r.MaxAgeDays < 0 || r.MaxAgeDays > 36500 {
|
||||
return errors.New("logx: invalid rotation limits")
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
value, max int
|
||||
}{{"maxSizeMb", r.MaxSizeMB, 1024 * 1024}, {"maxBackups", r.MaxBackups, 10000}, {"maxAgeDays", r.MaxAgeDays, 36500}} {
|
||||
if f.value < 0 || f.value > f.max {
|
||||
return configdiag.New("rotation."+f.name, fmt.Sprintf("must be between 0 and %d", f.max))
|
||||
}
|
||||
}
|
||||
if r.Enabled && (cfg.File == "" || cfg.Writer != nil) {
|
||||
return errors.New("logx: rotation requires a file and no custom writer")
|
||||
if r.Enabled && cfg.File == "" {
|
||||
return configdiag.New("file", "is required when rotation is enabled")
|
||||
}
|
||||
if r.Enabled && cfg.Writer != nil {
|
||||
return configdiag.New("rotation.enabled", "cannot be enabled with a custom writer")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
40
logx/timeZone.go
Normal file
40
logx/timeZone.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package logx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
|
||||
)
|
||||
|
||||
func logLocation(name string) (*time.Location, error) {
|
||||
if name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
location, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
return nil, configdiag.Wrap("timeZone", "must be an available IANA time zone or UTC", err)
|
||||
}
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// Convert only the record timestamp. User time.Time attributes retain their
|
||||
// original locations; child loggers inherit this instance's location.
|
||||
type zoneHandler struct {
|
||||
slog.Handler
|
||||
location *time.Location
|
||||
}
|
||||
|
||||
func (h zoneHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
if !r.Time.IsZero() {
|
||||
r.Time = r.Time.In(h.location)
|
||||
}
|
||||
return h.Handler.Handle(ctx, r)
|
||||
}
|
||||
func (h zoneHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return zoneHandler{h.Handler.WithAttrs(attrs), h.location}
|
||||
}
|
||||
func (h zoneHandler) WithGroup(name string) slog.Handler {
|
||||
return zoneHandler{h.Handler.WithGroup(name), h.location}
|
||||
}
|
||||
133
logx/timeZone_test.go
Normal file
133
logx/timeZone_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package logx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoggerTimeZoneIsolation(t *testing.T) {
|
||||
originalLocal := time.Local
|
||||
stamp := time.Date(2026, 7, 1, 0, 0, 0, 123456000, time.UTC)
|
||||
for _, format := range []string{"json", "text"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
var a, b bytes.Buffer
|
||||
sh, err := New(Config{TimeZone: "Asia/Shanghai", Format: format, Writer: &a})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sh.Close()
|
||||
utc, err := New(Config{TimeZone: "UTC", Format: format, Writer: &b})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer utc.Close()
|
||||
r := slog.NewRecord(stamp, slog.LevelInfo, "timestamp", 0)
|
||||
r.AddAttrs(slog.Time("eventTime", stamp), slog.String("password", "secret"))
|
||||
if err := sh.With("service", "first").WithGroup("data").logger.Handler().Handle(context.Background(), r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := utc.logger.Handler().Handle(context.Background(), r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(a.String(), "08:00:00") || !strings.Contains(a.String(), "+08:00") || !strings.Contains(b.String(), "00:00:00") || strings.Contains(b.String(), "+08:00") {
|
||||
t.Fatalf("a=%s b=%s", a.String(), b.String())
|
||||
}
|
||||
if strings.Contains(a.String(), "secret") || strings.Contains(b.String(), "secret") {
|
||||
t.Fatal("redaction lost")
|
||||
}
|
||||
if format == "json" {
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(a.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["time"] != "2026-07-01T08:00:00.123456+08:00" || out["data"].(map[string]any)["eventTime"] != "2026-07-01T00:00:00.123456Z" {
|
||||
t.Fatal(out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if time.Local != originalLocal {
|
||||
t.Fatal("global time.Local changed")
|
||||
}
|
||||
}
|
||||
func TestLoggerTimeZoneValidationAndDST(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "not-created", "app.log")
|
||||
if _, err := New(Config{TimeZone: "Secret/Not_A_Zone", File: path}); err == nil || strings.Contains(err.Error(), "Secret") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
|
||||
t.Fatal("invalid zone opened writer")
|
||||
}
|
||||
var out bytes.Buffer
|
||||
l, err := New(Config{TimeZone: "America/New_York", Writer: &out})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
for _, month := range []time.Month{time.January, time.July} {
|
||||
r := slog.NewRecord(time.Date(2026, month, 1, 12, 0, 0, 0, time.UTC), slog.LevelInfo, "dst", 0)
|
||||
if err := l.logger.Handler().Handle(context.Background(), r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out.String(), "-05:00") || !strings.Contains(out.String(), "-04:00") {
|
||||
t.Fatal(out.String())
|
||||
}
|
||||
// Explicit empty location preserves an incoming record's original zone.
|
||||
out.Reset()
|
||||
plain, _ := New(Config{Writer: &out})
|
||||
defer plain.Close()
|
||||
r := slog.NewRecord(time.Date(2026, 1, 1, 0, 0, 0, 0, time.FixedZone("original", 7200)), slog.LevelInfo, "unchanged", 0)
|
||||
plain.logger.Handler().Handle(context.Background(), r)
|
||||
if !strings.Contains(out.String(), "+02:00") {
|
||||
t.Fatal(out.String())
|
||||
}
|
||||
}
|
||||
func TestLoggerTimeZoneConcurrentChildren(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
l, err := New(Config{TimeZone: "Asia/Shanghai", Writer: &out})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
child := l.With("worker", i).WithGroup("event")
|
||||
for n := 0; n < 10; n++ {
|
||||
child.Standard(slog.LevelInfo).Print("message")
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if strings.Count(out.String(), "+08:00") != 200 {
|
||||
t.Fatal("timestamps missing")
|
||||
}
|
||||
}
|
||||
func BenchmarkLoggerTimeZone(b *testing.B) {
|
||||
for _, zone := range []string{"", "UTC", "Asia/Shanghai"} {
|
||||
b.Run(zone, func(b *testing.B) {
|
||||
l, err := New(Config{TimeZone: zone, Writer: io.Discard})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
l.InfoContext(context.Background(), "benchmark", "count", 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user