feat: add precise diagnostics and optional client copy logging controls

This commit is contained in:
2026-09-26 18:04:33 +08:00
parent 69585dab61
commit 3bbc6f60d7
44 changed files with 1529 additions and 84 deletions

View File

@@ -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 返回后继续写入的自管流。

View File

@@ -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{}

View File

@@ -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)

View File

@@ -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
View 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
View 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)
}
})
}
}