diff --git a/.gitignore b/.gitignore index 813097b..a6d1246 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ target/ # 编辑器/系统 .DS_Store *.swp + +# Claude Code 本机配置(不入库) +.claude/ diff --git a/go/README.md b/go/README.md index 68bbfd6..fdd887d 100644 --- a/go/README.md +++ b/go/README.md @@ -2,9 +2,12 @@ opensourceways 微服务可观测薄封装 SDK 的 Go 实现,契约见 [spec/](../spec/README.md)。 -- **日志**:`log` package —— 加锁单行 JSON 写 stdout(`encoding/json` marshal map),字段规范见 spec/log-format.md +- **日志**:`log` package —— 基于 stdlib `log/slog` 的自定义 Handler,单行扁平 JSON 写 stdout,字段规范见 spec/log-format.md + - **只提供 kv 传参**(`msg` 常量 + 交替 `key, value`),**无 printf 变体** —— 与 `log/slog`、kratos v3 一致;可查询的数据必须走字段 + - 包级 API 形状对齐 kratos v3:`Init` 之后直接 `log.Info(...)` / `log.InfoContext(ctx, ...)`,无需在每个调用点先绑定 logger + - `error` 值统一序列化为 `err.Error()` 文本(`encoding/json` 会把多数错误渲染成 `{}`) - **指标**:`metrics` package —— `client_golang` 薄封装(counter/gauge/histogram),label 规范见 spec/metrics-format.md -- **请求上下文**:`sdkctx` —— `context.Context` 承载 `community/request_id/trace_id` +- **请求上下文**:`sdkctx` —— `context.Context` 承载 `community/request_id/trace_id/span_id` - **中间件**:`middleware`(net/http)+ `middleware/ginmw`(gin)—— 注入 request_id、可信判定点解析 community、记 `obs_http_server_*` 指标 - **community 双层注入**:`service/env/instance` 部署级 const label;`community` 普通可变 label —— 请求上下文覆盖,未覆盖回退部署默认(`OBS_*` 环境变量) @@ -12,8 +15,8 @@ opensourceways 微服务可观测薄封装 SDK 的 Go 实现,契约见 [spec/] | package | 说明 | | --- | --- | -| [sdkctx](sdkctx/context.go) | `Request{Community,RequestID,TraceID}` + `WithCommunity/WithRequestID/WithTraceID` + `From/Community/RequestID/TraceID` | -| [log](log/) | `New(cfg Config) *Logger`,方法 `Info/Warn/Debug/Error(msg, kv...)`,`With(kv...)`、`WithRequest(ctx)` | +| [sdkctx](sdkctx/context.go) | `Request{Community,RequestID,TraceID,SpanID}` + `WithCommunity/WithRequestID/WithTraceID/WithSpanID` + `From/Community/RequestID/TraceID/SpanID` | +| [log](log/) | `Init(cfg Config)`;包级 `Info/Warn/Debug/Error(msg, kv...)` + `InfoContext(ctx, msg, kv...)` + `Log/LogAttrs`;二级 API `New(cfg) *slog.Logger`、`With(kv...)`、`SetDefault/Default` | | [metrics](metrics/) | `New(cfg Config) *Metrics`;`NewCounterVec/NewGaugeVec/NewHistogramVec(WithBuckets)(name, help, businessLabels...)`;`Handler()`(promhttp) | | [middleware](middleware/) | `New(opts Options).Then(http.Handler)`,net/http | | [middleware/ginmw](middleware/ginmw/) | `Middleware(opts Options) gin.HandlerFunc` | @@ -28,10 +31,18 @@ import ( "github.com/opensourceways/obs-sdk/go/sdkctx" ) -// 日志:字段空则回退 OBS_SERVICE / OBS_ENV / OBS_INSTANCE / OBS_COMMUNITY -l := obslog.New(obslog.Config{Service: "review", Env: "test", +// 日志:Init 一次,之后全进程直接用包级函数。 +// 字段空则回退 OBS_SERVICE / OBS_ENV / OBS_INSTANCE / OBS_COMMUNITY +obslog.Init(obslog.Config{Service: "review", Env: "test", Instance: "pod-1", Community: "openeuler"}) -l.Info("job done", "event", "release", "issue", "2061") + +// 无请求上下文(如启动阶段):字段 = 常驻字段 + kv +obslog.Info("job done", "event", "release", "issue", "2061") + +// msg 是常量短语,可变数据一律走 kv(禁止 printf 风格格式化进 msg) +// 二级 API 仍可用:自定义 writer / 预置字段 / 接入自定义 slog 装配 +l := obslog.New(obslog.Config{Service: "review"}) +l.With("component", "webhook").Info("job done", "issue", "2061") // 指标:注册一次,community 值按请求覆盖或回退部署默认 m := obsmetrics.New(obsmetrics.Config{Service: "review", Env: "test", @@ -54,9 +65,14 @@ http.ListenAndServe(":8080", h) // gin 变体:import "github.com/opensourceways/obs-sdk/go/middleware/ginmw" // r.Use(ginmw.Middleware(ginmw.Options{Metrics: m})) -// 业务请求内覆盖 community / request_id / trace_id(trace_id 预留位,不落 span) +// 业务请求内覆盖 community / request_id / trace_id / span_id +//(trace_id / span_id 为二期预留注入位,首期恒空、有值才输出) ctx := sdkctx.WithCommunity(r.Context(), "mindspore") -l.WithRequest(ctx).Info("scoped") +ctx = sdkctx.WithRequestID(ctx, "req-123") + +// 日志:request_id / community 覆盖 / trace_id 自动附加,无需先绑定 logger +obslog.ErrorContext(ctx, "get account failed", "user_id", uid, "error", err) +// 指标:community label 取覆盖值 built.IncWithContext(ctx, "tag") ``` diff --git a/go/log/handler.go b/go/log/handler.go new file mode 100644 index 0000000..440799e --- /dev/null +++ b/go/log/handler.go @@ -0,0 +1,329 @@ +package log + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/opensourceways/obs-sdk/go/sdkctx" +) + +// 契约字段名(spec/log-format.md 顶层字段表)。集中定义,避免各处拼写漂移。 +const ( + keyTime = "time" + keyLevel = "level" + keyMsg = "msg" + keyService = "service" + keyEnv = "env" + keyInstance = "instance" + keyCommunity = "community" + keyRequestID = "request_id" + keyTraceID = "trace_id" + keySpanID = "span_id" + keyLogger = "logger" +) + +// timeFormat 固定 3 位毫秒的 UTC 布局(契约要求 UTC + 固定毫秒精度)。 +// 已约定先 .UTC() 再格式化,故 Z07:00 恒输出 "Z"。 +const timeFormat = "2006-01-02T15:04:05.000Z07:00" + +// handlerConfig 是 Handler 的静态配置,Init 时确定、运行期不变。 +type handlerConfig struct { + service string + env string + instance string + community string + level slog.Level + output io.Writer + disableSource bool +} + +// handler 是遵循 spec/log-format.md 的 slog.Handler:把一条记录写成单行扁平 JSON。 +// +// 请求级字段(request_id / trace_id / span_id / community 覆盖值)在 Handle 时从 +// 传入的 ctx 读取 sdkctx —— 这正是 InfoContext(ctx, ...) 能自动带上请求字段的原因, +// 调用方无需在每个日志点先绑定一个带上下文的 logger。 +type handler struct { + cfg handlerConfig + + // groups 是当前分组前缀(slog WithGroup);契约要求扁平 JSON,故以 "." 连接后平铺。 + groups []string + // preAttrs 是 With(...) 预先附加的属性;prefix 记录其写入时的分组前缀。 + preAttrs []preAttr + + // mu 串行化写出,保证并发下整行不被撕裂。所有派生 handler 共享同一把锁。 + mu *sync.Mutex +} + +// preAttr 是带分组前缀的预置属性。 +type preAttr struct { + prefix string + attr slog.Attr +} + +func newHandler(cfg handlerConfig) *handler { + return &handler{cfg: cfg, mu: &sync.Mutex{}} +} + +// Enabled 按初始化级别过滤。 +func (h *handler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.cfg.level +} + +// WithAttrs 返回携带预置属性的副本(不改原 handler,可并发派生)。 +func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler { + if len(attrs) == 0 { + return h + } + prefix := strings.Join(h.groups, ".") + nh := h.clone() + for _, a := range attrs { + nh.preAttrs = append(nh.preAttrs, preAttr{prefix: prefix, attr: a}) + } + return nh +} + +// WithGroup 返回开启分组的副本。 +func (h *handler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + nh := h.clone() + nh.groups = append(nh.groups, name) + return nh +} + +// clone 深拷贝可变切片(共享 mu)。显式拷贝而非共享底层数组, +// 否则并发 WithAttrs/WithGroup 派生会在同一底层数组上竞争写入。 +func (h *handler) clone() *handler { + nh := &handler{cfg: h.cfg, mu: h.mu} + nh.groups = append(nh.groups, h.groups...) + nh.preAttrs = append(nh.preAttrs, h.preAttrs...) + return nh +} + +// Handle 按契约字段顺序写出单行 JSON。 +func (h *handler) Handle(ctx context.Context, r slog.Record) error { + var buf bytes.Buffer + buf.Grow(256) + o := &jsonObject{buf: &buf} + buf.WriteByte('{') + + o.str(keyTime, r.Time.UTC().Format(timeFormat)) + o.str(keyLevel, levelName(r.Level)) + o.str(keyMsg, r.Message) + o.str(keyService, h.cfg.service) + o.str(keyEnv, h.cfg.env) + o.str(keyInstance, h.cfg.instance) + + // 请求级字段:community 覆盖值优先于部署级默认;其余为空时省略该键。 + req := sdkctx.From(ctx) + community := h.cfg.community + if req.Community != "" { + community = req.Community + } + o.str(keyCommunity, community) + if req.RequestID != "" { + o.str(keyRequestID, req.RequestID) + } + if req.TraceID != "" { + o.str(keyTraceID, req.TraceID) + } + if req.SpanID != "" { + o.str(keySpanID, req.SpanID) + } + + // 调试定位:调用位置来自 record.PC(包级函数已按调用方 PC 构造 record)。 + if !h.cfg.disableSource { + if src := sourceName(r.PC); src != "" { + o.str(keyLogger, src) + } + } + + // 业务字段:With(...) 预置的在前,记录自带的在后(后者可覆盖前者)。 + for _, pa := range h.preAttrs { + h.appendAttr(o, pa.prefix, pa.attr) + } + prefix := strings.Join(h.groups, ".") + r.Attrs(func(a slog.Attr) bool { + h.appendAttr(o, prefix, a) + return true + }) + + buf.WriteByte('}') + buf.WriteByte('\n') + + h.mu.Lock() + defer h.mu.Unlock() + _, err := h.cfg.output.Write(buf.Bytes()) + return err +} + +// appendAttr 把一个属性平铺写入目标对象;分组属性递归展开为 "group.key"。 +func (h *handler) appendAttr(o *jsonObject, prefix string, a slog.Attr) { + a.Value = a.Value.Resolve() // 解开 LogValuer + if a.Equal(slog.Attr{}) { + return + } + if a.Value.Kind() == slog.KindGroup { + // 空键分组表示「就地展开」,前缀不变;否则以组名为前缀下钻。 + sub := prefix + if a.Key != "" { + sub = joinKey(prefix, a.Key) + } + for _, ga := range a.Value.Group() { + h.appendAttr(o, sub, ga) + } + return + } + o.value(joinKey(prefix, a.Key), a.Value) +} + +func joinKey(prefix, key string) string { + if prefix == "" { + return key + } + return prefix + "." + key +} + +// levelName 输出契约要求的小写 level 枚举。 +// slog.Level.String() 为大写(INFO),故此处单独映射;自定义级别按就近归类。 +func levelName(l slog.Level) string { + switch { + case l >= LevelFatal: + return "fatal" + case l >= slog.LevelError: + return "error" + case l >= slog.LevelWarn: + return "warn" + case l >= slog.LevelInfo: + return "info" + default: + return "debug" + } +} + +// sourceName 把 record.PC 解析为调用位置,形如 "service/todo.go:51"。 +// 只保留末两级路径,避免把构建机绝对路径写进生产日志。 +// +// 这里必须用 runtime.CallersFrames 而非 FuncForPC:record.PC 是 runtime.Callers +// 捕获的返回地址,FuncForPC 会把它归属到【上一层】帧(实测偏移一帧), +// CallersFrames 才按返回地址的正确语义展开(并正确处理内联帧)。 +func sourceName(pc uintptr) string { + if pc == 0 { + return "" + } + var pcs [1]uintptr + pcs[0] = pc + frame, _ := runtime.CallersFrames(pcs[:]).Next() + file, line := frame.File, frame.Line + if file == "" { + return "" + } + base := filepath.Base(file) + dir := filepath.Base(filepath.Dir(file)) + if dir == "." || dir == string(filepath.Separator) { + return base + ":" + strconv.Itoa(line) + } + return dir + "/" + base + ":" + strconv.Itoa(line) +} + +// jsonObject 按写入顺序拼装 JSON 对象,键序即契约字段表的顺序。 +// 不能用 map + json.Marshal:那样键序是按字典序排的,且 error 值会退化成 "{}"。 +type jsonObject struct { + buf *bytes.Buffer + count int +} + +// key 写入字段名(非首个字段前置逗号)。 +func (o *jsonObject) key(k string) { + if o.count > 0 { + o.buf.WriteByte(',') + } + o.count++ + o.buf.Write(appendJSONString(nil, k)) + o.buf.WriteByte(':') +} + +// str 写入字符串字段。 +func (o *jsonObject) str(k, v string) { + o.key(k) + o.buf.Write(appendJSONString(nil, v)) +} + +// value 写入 slog.Value 字段。 +func (o *jsonObject) value(k string, v slog.Value) { + o.key(k) + o.writeValue(v.Any()) +} + +func (o *jsonObject) writeValue(v any) { + switch x := v.(type) { + case nil: + o.buf.WriteString("null") + case error: + // error 一律取原因链文本:stdlib encoding/json 会把多数错误类型渲染成 "{}", + // 静默丢掉错误内容。此处是契约里 error 字段的唯一写法。 + o.buf.Write(appendJSONString(nil, x.Error())) + case time.Time: + o.buf.Write(appendJSONString(nil, x.UTC().Format(timeFormat))) + case time.Duration: + // 输出 "1.5s" 这类可读文本,而非纳秒整数。 + o.buf.Write(appendJSONString(nil, x.String())) + default: + b, err := json.Marshal(v) + if err != nil { + // 不可序列化的值降级为文本,保证永远不丢日志行。 + o.buf.Write(appendJSONString(nil, fmt.Sprint(v))) + return + } + o.buf.Write(b) + } +} + +// appendJSONString 追加一个 JSON 字符串字面量。 +// +// 只转义 JSON 规范要求的字符(`"`、`\`、控制字符),不转义 `<` `>` `&` —— 与 +// stdlib log/slog 的 JSONHandler 行为一致,日志可读性更好。非法 UTF-8 交给 +// encoding/json 做 U+FFFD 替换,避免产出的行不是合法 JSON 而撑爆 LTS 解析。 +func appendJSONString(dst []byte, s string) []byte { + if !utf8.ValidString(s) { + b, err := json.Marshal(s) + if err != nil { + return append(dst, '"', '"') + } + return append(dst, b...) + } + const hex = "0123456789abcdef" + dst = append(dst, '"') + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c == '"': + dst = append(dst, '\\', '"') + case c == '\\': + dst = append(dst, '\\', '\\') + case c == '\n': + dst = append(dst, '\\', 'n') + case c == '\r': + dst = append(dst, '\\', 'r') + case c == '\t': + dst = append(dst, '\\', 't') + case c < 0x20: + dst = append(dst, '\\', 'u', '0', '0', hex[c>>4], hex[c&0xF]) + default: + dst = append(dst, c) + } + } + return append(dst, '"') +} diff --git a/go/log/log.go b/go/log/log.go index 1c2add3..38e2d47 100644 --- a/go/log/log.go +++ b/go/log/log.go @@ -1,72 +1,80 @@ // Package log 提供结构化 JSON 日志(obs-sdk-go 的 log 部分)。 // -// 输出格式与字段遵循 spec/log-format.md 与 spec/common-fields.md: -// - 单行 JSON(不 pretty),经 stdout 采集进 LTS; -// - 常驻字段 service / env / instance / community 在进程启动时注入; -// - request_id / community 覆盖值 / trace_id(预留)在请求上下文存在时附加; -// - trace_id 预留注入位:二期 trace 经 sdkctx.WithTraceID 注入即可见,零返工。 +// 输出字段遵循 spec/log-format.md 与 spec/common-fields.md: +// - 单行扁平 JSON(不 pretty)写 stdout,经 log-agent 采集进 LTS; +// - 常驻字段 service / env / instance / community 在 Init 时注入; +// - request_id / community 覆盖值 / trace_id / span_id 由 Handler 在 +// Handle(ctx, ...) 时从 ctx 中的 sdkctx 读取,故 InfoContext 等变体自动带上。 +// +// 设计约束: +// - 只提供 kv 形式(msg 常量 + 交替 key/value),**无 printf 变体** —— 与 stdlib +// log/slog、kratos v3 一致;可查询的数据必须走字段,不能格式化进 msg 文本。 +// - 包级函数形状对齐 kratos v3:Init 之后直接 log.Info(...) / log.InfoContext(ctx, ...), +// 无需在每个调用点先绑定请求上下文。 +// - error 值统一序列化为 err.Error() 文本(encoding/json 会把多数错误渲染成 "{}")。 +// - trace_id / span_id 为二期预留注入位,首期恒空、有值才输出,二期接入零返工。 // // 用法: // -// l := log.New(log.Config{Service: "robot-universal-review"}) -// l.Info("handle webhook", "event", "pull_request", "action", "opened") +// log.Init(log.Config{Service: "robot-universal-review"}) // -// 请求级覆盖: +// // 无请求上下文 +// log.Info("server started", "addr", ":8080") // -// l := log.New(log.Config{Service: "srv"}) -// ctx := sdkctx.WithCommunity(r.Context(), "openeuler") -// l.WithRequest(ctx).Info("by community", "path", r.URL.Path) +// // 有请求上下文:request_id / community / trace_id 自动附加 +// log.ErrorContext(ctx, "get account failed", "user_id", uid, "error", err) package log import ( "context" - "encoding/json" - "fmt" "io" + "log/slog" "os" + "runtime" "strings" - "sync" "time" "github.com/opensourceways/obs-sdk/go/internal/env" - "github.com/opensourceways/obs-sdk/go/sdkctx" ) -// Level 日志级别。 -type Level int +// Level 是日志级别,别名到 slog.Level,保证与 slog 生态互通。 +type Level = slog.Level + +// Leveler / LevelVar 别名,便于调用方无需直接 import log/slog。 +type ( + // Leveler 提供日志级别。 + Leveler = slog.Leveler + // LevelVar 是可运行时变更的级别。 + LevelVar = slog.LevelVar +) const ( - LevelDebug Level = iota - LevelInfo - LevelWarn - LevelError + // LevelDebug 调试级别。 + LevelDebug Level = slog.LevelDebug + // LevelInfo 信息级别。 + LevelInfo Level = slog.LevelInfo + // LevelWarn 告警级别。 + LevelWarn Level = slog.LevelWarn + // LevelError 错误级别。 + LevelError Level = slog.LevelError + // LevelFatal 致命级别。是否 os.Exit 由调用方决定,日志层只按 "fatal" 输出 level。 + LevelFatal Level = slog.LevelError + 4 ) -// ParseLevel 解析级别字符串;不识别的默认 LevelInfo。 +// ParseLevel 解析级别字符串(大小写不敏感);不识别的默认 LevelInfo。 func ParseLevel(s string) Level { - switch strings.ToLower(strings.TrimSpace(s)) { - case "debug": - return LevelDebug - case "warn", "warning": + s = strings.ToUpper(strings.TrimSpace(s)) + switch s { + case "FATAL": + return LevelFatal + case "WARNING": return LevelWarn - case "error": - return LevelError - default: - return LevelInfo } -} - -func (l Level) String() string { - switch l { - case LevelDebug: - return "debug" - case LevelWarn: - return "warn" - case LevelError: - return "error" - default: - return "info" + var level slog.Level + if err := level.UnmarshalText([]byte(s)); err == nil { + return level } + return LevelInfo } // Config 日志初始化配置。空字段会回退到 OBS_* 环境变量 / 内置默认。 @@ -79,158 +87,112 @@ type Config struct { Instance string // Community 部署级默认社区。缺省取 OBS_COMMUNITY,再否则 "unknown"。 Community string - // Level 最小输出级别。空字符串等价 LevelInfo。 + // Level 最小输出级别(debug/info/warn/error)。空字符串等价 LevelInfo。 Level string // Output 日志输出 Writer。默认 os.Stdout。 Output io.Writer + // DisableSource 关闭 logger 字段(调用位置,形如 service/todo.go:51)。 + // 默认输出;高吞吐场景可置 true,省去每次写出前的栈符号化开销。 + DisableSource bool } -// Logger 结构化 JSON 日志器。方法并发安全。 -type Logger struct { - mu sync.Mutex - - service string - envName string - instance string - // community 为部署级默认值;请求覆盖值在处理时优先。 - community string - level Level - out io.Writer - - // req 保存 WithRequest 绑定的请求级字段;nil 表示未绑定。 - req *sdkctx.Request - - // baseFields 是 With(kv...) 附加的字段,构建器模式叠加。 - baseFields map[string]any -} - -// New 创建日志器。缺省配置取环境变量回退。 -func New(cfg Config) *Logger { +// New 依据配置构建 *slog.Logger(自定义 Handler,输出契约 JSON),不改动全局默认。 +func New(cfg Config) *slog.Logger { out := cfg.Output if out == nil { out = os.Stdout } - return &Logger{ - service: env.Service(cfg.Service), - envName: env.Env(cfg.Env), - instance: env.Instance(cfg.Instance), - community: env.Community(cfg.Community), - level: ParseLevel(cfg.Level), - out: out, - baseFields: map[string]any{}, - } + return slog.New(newHandler(handlerConfig{ + service: env.Service(cfg.Service), + env: env.Env(cfg.Env), + instance: env.Instance(cfg.Instance), + community: env.Community(cfg.Community), + level: ParseLevel(cfg.Level), + output: out, + disableSource: cfg.DisableSource, + })) } -// Service 返回注入的服务名。 -func (l *Logger) Service() string { return l.service } +// Init 构建 logger 并设为全局默认(包级函数与 slog.Default 此后都走它)。 +// 服务启动时调用一次;未调用时包级函数走 slog 的默认(纯文本)Handler。 +func Init(cfg Config) { + SetDefault(New(cfg)) +} -// Community 返回部署级默认社区。 -func (l *Logger) Community() string { return l.community } +// SetDefault 设为全局默认 logger(同步影响 slog.Default)。 +func SetDefault(l *slog.Logger) { slog.SetDefault(l) } -// With 返回携带附加字段的子日志器(叠加不改原日志器)。 -func (l *Logger) With(kv ...any) *Logger { - cp := l.clone() - for k, v := range toMap(kv) { - cp.baseFields[k] = v - } - return cp -} +// Default 返回当前全局默认 logger。 +func Default() *slog.Logger { return slog.Default() } -// WithRequest 返回绑定请求上下文的子日志器:该请求日志自动携带 -// sdkctx 中 request_id / community(覆盖)/ trace_id(预留)。 -func (l *Logger) WithRequest(ctx context.Context) *Logger { - cp := l.clone() - if ctx != nil { - if v := sdkctx.From(ctx); v != (sdkctx.Request{}) { - cp.req = &v - } - } - return cp -} +// With 返回携带附加属性的 logger(镜像 slog.Logger.With,不改原 logger)。 +func With(args ...any) *slog.Logger { return slog.With(args...) } -func (l *Logger) clone() *Logger { - cp := &Logger{ - service: l.service, - envName: l.envName, - instance: l.instance, - community: l.community, - level: l.level, - out: l.out, - req: l.req, - baseFields: make(map[string]any, len(l.baseFields)+4), - } - for k, v := range l.baseFields { - cp.baseFields[k] = v - } - return cp -} +// WithGroup 返回开启分组的 logger(契约要求扁平 JSON,分组以 "name." 前缀平铺)。 +func WithGroup(name string) *slog.Logger { return Default().WithGroup(name) } -// --- 级别方法 --- +// Handler 返回默认 logger 的 Handler。 +func Handler() slog.Handler { return Default().Handler() } -func (l *Logger) Debug(msg string, kv ...any) { l.log(LevelDebug, msg, kv) } -func (l *Logger) Info(msg string, kv ...any) { l.log(LevelInfo, msg, kv) } -func (l *Logger) Warn(msg string, kv ...any) { l.log(LevelWarn, msg, kv) } -func (l *Logger) Error(msg string, kv ...any) { l.log(LevelError, msg, kv) } +// Enabled 报告默认 logger 在给定 ctx / level 下是否会输出。 +func Enabled(ctx context.Context, level Level) bool { return Default().Enabled(ctx, level) } -func (l *Logger) log(level Level, msg string, kv []any) { - if level < l.level { - return - } +// Debug 输出 debug 级日志(无请求上下文)。 +func Debug(msg string, args ...any) { log(context.Background(), LevelDebug, msg, args) } - fields := make(map[string]any, len(l.baseFields)+len(kv)/2+8) - for k, v := range l.baseFields { - fields[k] = v - } - for k, v := range toMap(kv) { - fields[k] = v - } +// DebugContext 输出 debug 级日志,并附加 ctx 中的请求级字段。 +func DebugContext(ctx context.Context, msg string, args ...any) { log(ctx, LevelDebug, msg, args) } - // 请求级字段注入(覆盖静态默认)。 - if l.req != nil { - if l.req.Community != "" { - fields["community"] = l.req.Community - } - if l.req.RequestID != "" { - fields["request_id"] = l.req.RequestID - } - if l.req.TraceID != "" { - fields["trace_id"] = l.req.TraceID - } - } +// Info 输出 info 级日志(无请求上下文)。 +func Info(msg string, args ...any) { log(context.Background(), LevelInfo, msg, args) } - // 常驻字段。community 未被请求覆盖时用部署级默认。 - if _, ok := fields["community"]; !ok { - fields["community"] = l.community - } - fields["service"] = l.service - fields["env"] = l.envName - fields["instance"] = l.instance - fields["level"] = level.String() - fields["msg"] = msg - fields["time"] = time.Now().UTC().Format(time.RFC3339Nano) - - line, err := json.Marshal(fields) - if err != nil { - line, _ = json.Marshal(map[string]any{ - "level": level.String(), "msg": msg, "service": l.service, - "error": fmt.Sprintf("marshal log fields: %v", err), - }) - } +// InfoContext 输出 info 级日志,并附加 ctx 中的请求级字段。 +func InfoContext(ctx context.Context, msg string, args ...any) { log(ctx, LevelInfo, msg, args) } + +// Warn 输出 warn 级日志(无请求上下文)。 +func Warn(msg string, args ...any) { log(context.Background(), LevelWarn, msg, args) } - l.mu.Lock() - defer l.mu.Unlock() - _, _ = l.out.Write(append(line, '\n')) +// WarnContext 输出 warn 级日志,并附加 ctx 中的请求级字段。 +func WarnContext(ctx context.Context, msg string, args ...any) { log(ctx, LevelWarn, msg, args) } + +// Error 输出 error 级日志(无请求上下文)。 +func Error(msg string, args ...any) { log(context.Background(), LevelError, msg, args) } + +// ErrorContext 输出 error 级日志,并附加 ctx 中的请求级字段。 +func ErrorContext(ctx context.Context, msg string, args ...any) { log(ctx, LevelError, msg, args) } + +// Log 输出指定级别的日志。 +func Log(ctx context.Context, level Level, msg string, args ...any) { + log(ctx, level, msg, args) } -// toMap 把 kv... 对拍平为 map;奇数长度忽略末尾无键值。 -func toMap(kv []any) map[string]any { - m := map[string]any{} - for i := 0; i+1 < len(kv); i += 2 { - key, ok := kv[i].(string) - if !ok { - continue - } - m[key] = kv[i+1] +// LogAttrs 输出指定级别的日志,属性以已类型化的 slog.Attr 传入。 +func LogAttrs(ctx context.Context, level Level, msg string, attrs ...slog.Attr) { + h := Default().Handler() + if !h.Enabled(ctx, level) { + return + } + var pcs [1]uintptr + // 跳过 [runtime.Callers, logAttrs, LogAttrs],让 logger 字段指向调用方业务代码。 + runtime.Callers(3, pcs[:]) + record := slog.NewRecord(time.Now(), level, msg, pcs[0]) + record.AddAttrs(attrs...) + _ = h.Handle(ctx, record) +} + +// log 是所有包级日志函数的唯一出口。 +// +// 这里手工构造 record 而非复用 slog.Logger.Log:后者的 PC 会落在本包包装函数上, +// 导致 logger 字段指错位置。skip=3 跳过 [runtime.Callers, log, 导出函数], +// 使 PC 指向调用方业务代码 —— 因此所有导出函数必须【直接】调用本函数。 +func log(ctx context.Context, level Level, msg string, args []any) { + h := Default().Handler() + if !h.Enabled(ctx, level) { + return } - return m + var pcs [1]uintptr + runtime.Callers(3, pcs[:]) + record := slog.NewRecord(time.Now(), level, msg, pcs[0]) + record.Add(args...) + _ = h.Handle(ctx, record) } diff --git a/go/log/log_test.go b/go/log/log_test.go index a464bb9..b4f20b7 100644 --- a/go/log/log_test.go +++ b/go/log/log_test.go @@ -4,38 +4,67 @@ import ( "bytes" "context" "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "strings" + "sync" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/opensourceways/obs-sdk/go/sdkctx" ) -// capture 构造写入 buffer 的 logger。 -func capture(cfg Config) (*Logger, *bytes.Buffer) { +// captureDefault 把包级 logger 指向 buffer;用例结束恢复原默认。 +func captureDefault(t *testing.T, cfg Config) *bytes.Buffer { + t.Helper() + prev := slog.Default() + t.Cleanup(func() { slog.SetDefault(prev) }) + + buf := &bytes.Buffer{} + cfg.Output = buf + Init(cfg) + return buf +} + +// newLogger 返回独立 logger + buffer,不触碰全局默认(用于 slog.Logger 直用路径)。 +func newLogger(t *testing.T, cfg Config) (*slog.Logger, *bytes.Buffer) { + t.Helper() buf := &bytes.Buffer{} cfg.Output = buf return New(cfg), buf } -func parseLine(t *testing.T, line []byte) map[string]any { +func parseLines(t *testing.T, buf *bytes.Buffer) []map[string]any { t.Helper() - var m map[string]any - assert.NoError(t, json.Unmarshal(line, &m)) - return m + raw := strings.TrimSpace(buf.String()) + if raw == "" { + return nil + } + lines := strings.Split(raw, "\n") + out := make([]map[string]any, 0, len(lines)) + for _, l := range lines { + var m map[string]any + require.NoError(t, json.Unmarshal([]byte(l), &m), "非法 JSON 行:%s", l) + out = append(out, m) + } + return out } func TestStaticFieldsInjected(t *testing.T) { - l, buf := capture(Config{ + buf := captureDefault(t, Config{ Service: "review", Env: "test", Instance: "pod-1", Community: "openeuler", }) - l.Info("hello", "event", "pull_request") + Info("hello", "event", "pull_request") - fields := parseLine(t, bytes.TrimSpace(buf.Bytes())) + fields := parseLines(t, buf)[0] assert.Equal(t, "info", fields["level"]) assert.Equal(t, "hello", fields["msg"]) assert.Equal(t, "review", fields["service"]) @@ -43,69 +72,212 @@ func TestStaticFieldsInjected(t *testing.T) { assert.Equal(t, "pod-1", fields["instance"]) assert.Equal(t, "openeuler", fields["community"]) assert.Equal(t, "pull_request", fields["event"]) - // 请求上下文缺省时 request_id / trace_id 不输出(键可缺省)。 - _, hasRid := fields["request_id"] - _, hasTid := fields["trace_id"] - assert.False(t, hasRid) - assert.False(t, hasTid) + // 请求上下文缺省时 request_id / trace_id / span_id 不输出(键可缺省)。 + for _, k := range []string{"request_id", "trace_id", "span_id"} { + _, has := fields[k] + assert.Falsef(t, has, "无上下文时不应输出 %s", k) + } } -func TestRequestCommunityOverride(t *testing.T) { - l, buf := capture(Config{ - Service: "review", - Community: "openeuler", - }) +// 核心能力:InfoContext 无需先绑定 logger,请求级字段自动从 ctx 附加。 +func TestContextVariantInjectsRequestFields(t *testing.T) { + buf := captureDefault(t, Config{Service: "review", Community: "openeuler"}) - // 业务在可信判定点写入覆盖社区。 ctx := sdkctx.WithCommunity(context.Background(), "mindspore") ctx = sdkctx.WithRequestID(ctx, "req-123") - l.WithRequest(ctx).Info("scoped", "k", "v") + ctx = sdkctx.WithTraceID(ctx, "trace-xyz") + ctx = sdkctx.WithSpanID(ctx, "span-abc") - fields := parseLine(t, bytes.TrimSpace(buf.Bytes())) - assert.Equal(t, "mindspore", fields["community"]) + InfoContext(ctx, "scoped", "k", "v") + + fields := parseLines(t, buf)[0] + assert.Equal(t, "mindspore", fields["community"]) // 请求级覆盖部署级默认 assert.Equal(t, "req-123", fields["request_id"]) + assert.Equal(t, "trace-xyz", fields["trace_id"]) + assert.Equal(t, "span-abc", fields["span_id"]) + assert.Equal(t, "v", fields["k"]) } func TestCommunityStaysDefaultWhenNoOverride(t *testing.T) { - l, buf := capture(Config{Service: "review", Community: "ascend"}) + buf := captureDefault(t, Config{Service: "review", Community: "ascend"}) + + InfoContext(context.Background(), "no override") + + assert.Equal(t, "ascend", parseLines(t, buf)[0]["community"]) +} - l.Info("no override") +// 二级 API:With(...) 附加字段,且能派生多个互不影响的子 logger。 +func TestWithAddsFields(t *testing.T) { + l, buf := newLogger(t, Config{Service: "review", DisableSource: true}) + + child := l.With("component", "webhook") + child.Info("with fields", "extra", 1) + l.Info("parent untouched") - fields := parseLine(t, bytes.TrimSpace(buf.Bytes())) - assert.Equal(t, "ascend", fields["community"]) + lines := parseLines(t, buf) + assert.Equal(t, "webhook", lines[0]["component"]) + assert.Equal(t, float64(1), lines[0]["extra"]) + _, has := lines[1]["component"] + assert.False(t, has, "父 logger 不应被子 logger 的 With 影响") } func TestLevelFilter(t *testing.T) { - l, buf := capture(Config{Service: "review", Level: "warn"}) + buf := captureDefault(t, Config{Service: "review", Level: "warn"}) - l.Info("dropped") - l.Warn("kept") + Debug("dropped") + Info("dropped") + Warn("kept") + Error("kept too") - // debug/info 被过滤,只留 warn 一行。 - lines := bytes.Split(bytes.TrimSpace(buf.Bytes()), []byte("\n")) - assert.Len(t, lines, 1) - fields := parseLine(t, lines[0]) - assert.Equal(t, "warn", fields["level"]) - assert.Equal(t, "kept", fields["msg"]) + lines := parseLines(t, buf) + require.Len(t, lines, 2) + assert.Equal(t, "warn", lines[0]["level"]) + assert.Equal(t, "kept", lines[0]["msg"]) + assert.Equal(t, "error", lines[1]["level"]) } -func TestWithAddsFields(t *testing.T) { - l, buf := capture(Config{Service: "review"}) - child := l.With("component", "webhook") - child.Info("with fields", "extra", 1) +func TestParseLevel(t *testing.T) { + assert.Equal(t, LevelDebug, ParseLevel("DEBUG")) + assert.Equal(t, LevelDebug, ParseLevel(" debug ")) + assert.Equal(t, LevelWarn, ParseLevel("warning")) + assert.Equal(t, LevelInfo, ParseLevel("")) + assert.Equal(t, LevelInfo, ParseLevel("bogus")) + assert.Equal(t, LevelFatal, ParseLevel("fatal")) +} - fields := parseLine(t, bytes.TrimSpace(buf.Bytes())) - assert.Equal(t, "webhook", fields["component"]) - assert.Equal(t, float64(1), fields["extra"]) +// 回归:错误值曾走 encoding/json 被序列化成 "{}",日志里只剩空对象、错误内容全丢。 +func TestErrorValueSerializedAsText(t *testing.T) { + buf := captureDefault(t, Config{Service: "review"}) + + ErrorContext(context.Background(), "get account failed", + "user_id", "u-123", "error", errors.New("connection refused")) + ErrorContext(context.Background(), "wrapped", + "error", fmt.Errorf("get account from db: %w", io.EOF)) + ErrorContext(context.Background(), "fatal level", + "error", fmt.Errorf("chain: %w", errors.New("boom"))) + + lines := parseLines(t, buf) + assert.Equal(t, "connection refused", lines[0]["error"]) + assert.Equal(t, "get account from db: EOF", lines[1]["error"]) + assert.Equal(t, "chain: boom", lines[2]["error"]) + + // 显式防线:原始输出里绝不能出现空对象形式的 error。 + assert.NotContains(t, buf.String(), `"error":{}`) } -func TestTraceIDReservedInject(t *testing.T) { - l, buf := capture(Config{Service: "review", Community: "openeuler"}) - // 二期:trace 经 WithTraceID 注入,日志零返工带上 trace_id。 - ctx := sdkctx.WithTraceID(context.Background(), "trace-xyz") - l.WithRequest(ctx).Info("with trace") +// 非 error 的复杂值仍走 JSON 序列化;不可序列化的降级为文本,不丢行。 +func TestNonErrorValuesKeepJSONShape(t *testing.T) { + buf := captureDefault(t, Config{Service: "review", DisableSource: true}) - fields := parseLine(t, bytes.TrimSpace(buf.Bytes())) - assert.Equal(t, "trace-xyz", fields["trace_id"]) - assert.Equal(t, "openeuler", fields["community"]) + Info("values", + "count", 42, + "ratio", 1.5, + "ok", true, + "nested", map[string]int{"a": 1}, + "list", []string{"x", "y"}, + "unsupported", func() {}, // json 无法序列化 → 降级为文本 + ) + + fields := parseLines(t, buf)[0] + assert.Equal(t, float64(42), fields["count"]) + assert.Equal(t, 1.5, fields["ratio"]) + assert.Equal(t, true, fields["ok"]) + assert.Equal(t, map[string]any{"a": float64(1)}, fields["nested"]) + assert.Equal(t, []any{"x", "y"}, fields["list"]) + assert.IsType(t, "", fields["unsupported"]) +} + +func TestTimeFormatMillisecondUTC(t *testing.T) { + buf := captureDefault(t, Config{Service: "review"}) + + Info("x") + + // 固定 3 位毫秒 + UTC 的 Z 后缀:2026-09-08T07:12:34.567Z + got := parseLines(t, buf)[0]["time"].(string) + assert.Regexp(t, `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$`, got) +} + +// logger 字段取自调用位置,且只保留末两级路径(不写构建机绝对路径)。 +func TestSourceMapsToLoggerField(t *testing.T) { + buf := captureDefault(t, Config{Service: "review"}) + + Info("caller location") + + fields := parseLines(t, buf)[0] + src, ok := fields["logger"].(string) + require.True(t, ok, "默认应输出 logger 字段") + assert.Contains(t, src, "log/log_test.go:") +} + +func TestSourceCanBeDisabled(t *testing.T) { + buf := captureDefault(t, Config{Service: "review", DisableSource: true}) + + Info("no caller location") + + _, has := parseLines(t, buf)[0]["logger"] + assert.False(t, has) +} + +// 契约要求扁平 JSON:WithGroup 不得产生嵌套对象。 +func TestGroupAttrsAreFlattened(t *testing.T) { + l, buf := newLogger(t, Config{Service: "review", DisableSource: true}) + + l.WithGroup("http").Info("req", "method", "GET") + + fields := parseLines(t, buf)[0] + assert.Equal(t, "GET", fields["http.method"]) + _, nested := fields["http"] + assert.False(t, nested, "不应出现嵌套对象") +} + +// 键序 = 契约字段表顺序(便于人眼扫读与按行提取)。 +func TestFieldOrderMatchesContract(t *testing.T) { + buf := captureDefault(t, Config{ + Service: "review", Env: "test", Instance: "pod-1", Community: "openeuler", + }) + + ctx := sdkctx.WithRequestID(context.Background(), "req-1") + InfoContext(ctx, "ordered", "biz", "z") + + line := strings.TrimSpace(buf.String()) + order := []string{"time", "level", "msg", "service", "env", "instance", "community", "request_id", "logger", "biz"} + prev := -1 + for _, k := range order { + idx := strings.Index(line, `"`+k+`":`) + require.Greaterf(t, idx, prev, "字段 %s 位置不符合契约顺序:%s", k, line) + prev = idx + } +} + +// 二级 API:slog.Logger 直用路径同样输出契约 JSON。 +func TestDirectSlogLogger(t *testing.T) { + l, buf := newLogger(t, Config{Service: "review", DisableSource: true}) + + l.Info("via slog.Logger", "k", "v") + + fields := parseLines(t, buf)[0] + assert.Equal(t, "review", fields["service"]) + assert.Equal(t, "v", fields["k"]) +} + +// 并发写出:每行都必须是完整合法 JSON(配合 -race 跑)。 +func TestConcurrentWrites(t *testing.T) { + const goroutines, perGoroutine = 8, 50 + + l, buf := newLogger(t, Config{Service: "review", DisableSource: true}) + + var wg sync.WaitGroup + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < perGoroutine; i++ { + l.Info("concurrent", "i", i) + } + }() + } + wg.Wait() + + lines := parseLines(t, buf) + assert.Len(t, lines, goroutines*perGoroutine) } diff --git a/go/sdkctx/context.go b/go/sdkctx/context.go index aaa19ff..b1dd41c 100644 --- a/go/sdkctx/context.go +++ b/go/sdkctx/context.go @@ -1,5 +1,5 @@ -// Package sdkctx 定义请求级通用字段(community / request_id / trace_id)在 -// context.Context 中的读写。log 与 metrics 两个 SDK package 共用本包,保证 +// Package sdkctx 定义请求级通用字段(community / request_id / trace_id / span_id) +// 在 context.Context 中的读写。log 与 metrics 两个 SDK package 共用本包,保证 // 双层注入(部署级默认 + 请求级覆盖)取自同一来源。 // // 设计约束(见 spec/common-fields.md):请求级 community 必须由业务在可信判定点 @@ -19,6 +19,8 @@ type Request struct { RequestID string // TraceID 预留位(二期 trace 接入);本期恒为空。 TraceID string + // SpanID 预留位(二期 trace 接入);本期恒为空。 + SpanID string } // WithCommunity 返回一个携带 community 覆盖值的新 context。 @@ -37,6 +39,11 @@ func WithTraceID(ctx context.Context, traceID string) context.Context { return withField(ctx, func(r *Request) { r.TraceID = traceID }) } +// WithSpanID 返回一个携带 span_id 的新 context(二期 trace 预留注入点)。 +func WithSpanID(ctx context.Context, spanID string) context.Context { + return withField(ctx, func(r *Request) { r.SpanID = spanID }) +} + // From 取出 context 中携带的请求级字段;未设置时返回零值 Request。 func From(ctx context.Context) Request { if ctx == nil { @@ -63,6 +70,11 @@ func TraceID(ctx context.Context) string { return From(ctx).TraceID } +// SpanID 返回当前 span_id;未设置返回空串。 +func SpanID(ctx context.Context) string { + return From(ctx).SpanID +} + func withField(ctx context.Context, mutate func(*Request)) context.Context { cur := From(ctx) mutate(&cur) diff --git a/go/sdkctx/context_test.go b/go/sdkctx/context_test.go index 96606d0..8244fc5 100644 --- a/go/sdkctx/context_test.go +++ b/go/sdkctx/context_test.go @@ -36,3 +36,12 @@ func TestTraceIDReserved(t *testing.T) { assert.Equal(t, "trace-abc", TraceID(ctx)) assert.Equal(t, "", RequestID(ctx)) } + +func TestSpanIDReserved(t *testing.T) { + // span_id 预留注入位:与 trace_id 同为二期预留,且互不干扰。 + ctx := WithTraceID(context.Background(), "trace-abc") + ctx = WithSpanID(ctx, "span-xyz") + assert.Equal(t, "span-xyz", SpanID(ctx)) + assert.Equal(t, "trace-abc", TraceID(ctx)) + assert.Equal(t, "", SpanID(context.Background())) +} diff --git a/java/README.md b/java/README.md index e388a8c..edf4a57 100644 --- a/java/README.md +++ b/java/README.md @@ -5,7 +5,7 @@ opensourceways 微服务可观测薄封装 SDK 的 Java 实现,契约见根目 - **community 双层注入**:`service/env/instance` 为部署级 const label,`community` 建模为普通可变 label, 值取请求上下文覆盖(可信判定点写入),未覆盖回退部署默认 —— 「注册一次两用」。 -- **`trace_id` 预留**:首期只保证字段可写可透传,不落 span。 +- **`trace_id` / `span_id` 预留**:首期只保证字段可写可透传,不落 span。 - **日志**:结构化 JSON(logback + logstash JSON encoder,MDC 输出固定键)。 - **指标**:Micrometer + Prometheus registry 薄封装(业务 counter/gauge/histogram); HTTP 服务端指标不重复造轮子,Java 服务走 Spring Boot Actuator + Micrometer 官方 server instrumentation。 @@ -14,8 +14,9 @@ opensourceways 微服务可观测薄封装 SDK 的 Java 实现,契约见根目 | 组件 | 说明 | | --- | --- | -| `context.RequestContext` | 请求级上下文(community/request_id/trace_id),ThreadLocal 作用域句柄,对齐其它语言的 sdkctx/contextvars/ALS | -| `log.ObsLogging` | 部署默认字段 + 请求覆盖字段写入 SLF4J MDC,由 JSON encoder 输出 | +| `context.RequestContext` | 请求级上下文(community/request_id/trace_id/span_id),ThreadLocal 作用域句柄,对齐其它语言的 sdkctx/contextvars/ALS | +| `log.ObsLogging` | 部署默认字段 + 请求覆盖字段写入 SLF4J MDC | +| `log.ObsJsonProvider` | logstash-logback-encoder 的 provider:按契约输出固定字段(时间/级别/字段名/顺序/异常堆栈) | | `ObsMetrics` | 业务指标装配(common tags + community 动态 label + namespace 前缀) | | `middleware.ObsFilter` | 可选 Servlet Filter:注入 request_id + 可信判定点解析 community → RequestContext + MDC | @@ -113,8 +114,31 @@ public FilterRegistrationBean obsFilter() { ## 日志 JSON 输出 把 `examples/logback-json.xml` 拷成接入服务的 logback 配置并引入 `logstash-logback-encoder`, -日志即输出单行 JSON(固定键 `service/env/instance/community/request_id/trace_id`),例: +日志即输出单行 JSON,例: ```json -{"@timestamp":"2026-09-08T09:00:00.000+08:00","level":"INFO","logger_name":"com.x.ReviewSvc","message":"hello","service":"review","env":"test","instance":"pod-1","community":"openeuler"} +{"time":"2026-09-10T08:13:42.725Z","level":"info","msg":"job done","service":"review","env":"test","instance":"pod-1","community":"openEuler","request_id":"req-1","logger":"ReviewSvc.java:51"} ``` + +固定字段的顺序、取值与缺失规则均由 SDK 的 `ObsJsonProvider` 保证,接入方无需(也无法)逐项配置: + +| 字段 | 说明 | +| --- | --- | +| `time` | 固定毫秒精度 UTC,以 `Z` 结尾(不受 JVM 时区影响) | +| `level` | 小写 `debug` / `info` / `warn` / `error`(`TRACE` 归入 `debug`) | +| `msg` | 格式化后的消息 | +| `service` / `env` / `instance` / `community` | 来自 MDC(`ObsLogging.init` 登记,`community` 可被请求上下文覆盖) | +| `request_id` / `trace_id` / `span_id` | 请求级,来自 MDC;空值省略(后两者为二期预留) | +| `logger` | 调用位置 `文件:行号`(对齐 Go 侧语义) | +| `error` | 异常完整堆栈,仅在有 throwable 时出现 | + +> **为什么不用 encoder 自带的 provider**:`` 只能输出大写 `INFO`(7.4 无配置项可改 +> 大小写,`` 输出的是数字),字段名走 `LogstashFieldNames` 而 +> `LoggingEventCompositeJsonEncoder` 没有 `setFieldNames`,且不配 `` 时 +> **throwable 会被整条丢弃**。故固定字段这一层由 SDK 自己的 provider 承担。 +> +> 业务字段不属于固定字段,可在 `ObsJsonProvider` **之后**追加 `` / `` +> 等 provider,输出会落在固定字段之后。 + +> 依赖:`logstash-logback-encoder` 与 `jackson-core` 在本 SDK 中为 `provided` scope +> (`ObsJsonProvider` 需要它们编译),运行时由接入服务提供,不随 SDK 传递。 diff --git a/java/examples/logback-json.xml b/java/examples/logback-json.xml index 5cfd942..6ea4cac 100644 --- a/java/examples/logback-json.xml +++ b/java/examples/logback-json.xml @@ -2,8 +2,17 @@ - service - env - instance - community - request_id - trace_id - + + + + System.out diff --git a/java/pom.xml b/java/pom.xml index f044508..a8649f0 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -19,6 +19,7 @@ 2.0.13 1.5.12 7.4 + 2.15.2 5.10.2 @@ -53,6 +54,14 @@ ${logstash-encoder.version} provided + + + com.fasterxml.jackson.core + jackson-core + ${jackson.version} + provided + jakarta.servlet diff --git a/java/src/main/java/io/opensourceways/obssdk/context/RequestContext.java b/java/src/main/java/io/opensourceways/obssdk/context/RequestContext.java index aa42bf5..e6efa2c 100644 --- a/java/src/main/java/io/opensourceways/obssdk/context/RequestContext.java +++ b/java/src/main/java/io/opensourceways/obssdk/context/RequestContext.java @@ -7,7 +7,7 @@ import java.util.function.Supplier; /** - * 请求级上下文:承载 {@code community / request_id / trace_id} 三个请求字段, + * 请求级上下文:承载 {@code community / request_id / trace_id / span_id} 四个请求字段, * 语义与其它语言 SDK 对齐 —— Go sdkctx(context.Context)、Python contextvars、Node AsyncLocalStorage。 * *

community 双层注入(见 spec/common-fields.md、community-values.md): @@ -16,8 +16,8 @@ * 后,通过 {@link #push} 写入本上下文;指标与日志读取 {@link #communityOverride()}, * 未覆盖时回退部署默认。 * - *

{@code trace_id} 为预留位(首期不做 trace):本次只保证字段在上下文中可写入、可透传, - * 供后续 trace 接入时读取,不产生任何 span。 + *

{@code trace_id} / {@code span_id} 为预留位(首期不做 trace):本次只保证字段在上下文中 + * 可写入、可透传,供后续 trace 接入时读取,不产生任何 span。 */ public final class RequestContext { @@ -27,16 +27,23 @@ public final class RequestContext { private final String community; private final String requestId; private final String traceId; + private final String spanId; - private RequestContext(String community, String requestId, String traceId) { + private RequestContext(String community, String requestId, String traceId, String spanId) { this.community = community; this.requestId = requestId; this.traceId = traceId; + this.spanId = spanId; } - /** 构造一个请求字段快照(通常由中间件调用)。 */ + /** 构造一个请求字段快照(通常由中间件调用)。{@code span_id} 为二期预留,首期传 null。 */ public static RequestContext of(String community, String requestId, String traceId) { - return new RequestContext(community, requestId, traceId); + return new RequestContext(community, requestId, traceId, null); + } + + /** 构造一个请求字段快照,含二期预留的 {@code span_id}。 */ + public static RequestContext of(String community, String requestId, String traceId, String spanId) { + return new RequestContext(community, requestId, traceId, spanId); } /** 用给定字段绑定当前线程,返回作用域句柄;离开作用域(close)后自动还原。 */ @@ -44,6 +51,11 @@ public static Scope push(String community, String requestId, String traceId) { return push(of(community, requestId, traceId)); } + /** 同 {@link #push(String, String, String)},额外携带二期预留的 {@code span_id}。 */ + public static Scope push(String community, String requestId, String traceId, String spanId) { + return push(of(community, requestId, traceId, spanId)); + } + /** 同 {@link #push(String, String, String)},复用已有快照。 */ public static Scope push(RequestContext ctx) { final RequestContext prev = HOLDER.get(); @@ -99,6 +111,11 @@ public static Optional currentTraceId() { return current().map(ctx -> ctx.traceId); } + /** 请求级 span_id(预留)。 */ + public static Optional currentSpanId() { + return current().map(ctx -> ctx.spanId); + } + /** 供日志装配读取的字段快照(写入 MDC)。 */ public Map asMdcFields() { Map fields = new HashMap<>(); @@ -111,6 +128,9 @@ public Map asMdcFields() { if (traceId != null) { fields.put("trace_id", traceId); } + if (spanId != null) { + fields.put("span_id", spanId); + } return fields; } @@ -126,6 +146,10 @@ public String traceId() { return traceId; } + public String spanId() { + return spanId; + } + /** 作用域句柄:{@link RequestContext#push} 的返回,close 时还原线程上下文。 */ @FunctionalInterface public interface Scope extends AutoCloseable { diff --git a/java/src/main/java/io/opensourceways/obssdk/log/ObsJsonProvider.java b/java/src/main/java/io/opensourceways/obssdk/log/ObsJsonProvider.java new file mode 100644 index 0000000..2d6e843 --- /dev/null +++ b/java/src/main/java/io/opensourceways/obssdk/log/ObsJsonProvider.java @@ -0,0 +1,117 @@ +package io.opensourceways.obssdk.log; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.ThrowableProxyUtil; +import com.fasterxml.jackson.core.JsonGenerator; +import net.logstash.logback.composite.AbstractJsonProvider; + +import java.io.IOException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.Map; + +/** + * obs-sdk 的 logstash-logback-encoder provider:按 spec/log-format.md 输出固定字段。 + * + *

为什么不用 encoder 自带的 provider 逐项拼装: + *

    + *
  • {@code } 只能输出大写 {@code INFO},契约要求小写,且 7.4 没有任何 + * 配置项可改大小写({@code } 输出的是数字 20000,不是名字);
  • + *
  • encode 的字段名走 {@code LogstashFieldNames},{@code LoggingEventCompositeJsonEncoder} + * 没有 {@code setFieldNames},只能逐个 provider 嵌套配置,样例会变得难以维护;
  • + *
  • 不配 {@code } 时 throwable 会被整条丢弃 —— 错误日志直接丢失原因, + * 这是最要命的一条。
  • + *
+ * 因此把「固定字段」这一层的所有权收到 SDK 内:一个 provider 按契约顺序输出全部固定字段, + * 接入方只需在配置里写一行 {@code }。 + * + *

字段顺序:{@code time / level / msg / service / env / instance / community / + * request_id / trace_id / span_id / logger / error}。其中 + * {@code service / env / instance / community} 来自 {@link ObsLogging#init} 写入的 MDC, + * 其余请求级字段由中间件经 {@link ObsLogging#enrich} 写入,空值一律省略 + * ({@code trace_id} / {@code span_id} 为二期预留,首期通常不出现)。 + * + *

业务字段不属于本 provider 的职责:接入方可在本 provider 之后追加 + * {@code } 或 {@code } 等 provider,输出会落在固定字段之后。 + */ +public class ObsJsonProvider extends AbstractJsonProvider { + + /** 与 spec/log-format.md 一致:固定毫秒精度、UTC、零偏移输出 Z(非 +00:00)。 */ + private static final DateTimeFormatter TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX").withZone(ZoneOffset.UTC); + + /** MDC 中属于固定契约的键,按契约顺序输出;空值省略。 */ + private static final String[] MDC_CONTRACT_KEYS = { + ObsLogging.MDC_SERVICE, + ObsLogging.MDC_ENV, + ObsLogging.MDC_INSTANCE, + ObsLogging.MDC_COMMUNITY, + ObsLogging.MDC_REQUEST_ID, + ObsLogging.MDC_TRACE_ID, + ObsLogging.MDC_SPAN_ID, + }; + + @Override + public void writeTo(JsonGenerator generator, ILoggingEvent event) throws IOException { + generator.writeStringField("time", TIME_FORMATTER.format(Instant.ofEpochMilli(event.getTimeStamp()))); + generator.writeStringField("level", levelName(event.getLevel())); + generator.writeStringField("msg", event.getFormattedMessage()); + + Map mdc = event.getMDCPropertyMap(); + for (String key : MDC_CONTRACT_KEYS) { + String value = mdc == null ? null : mdc.get(key); + if (value != null && !value.isEmpty()) { + generator.writeStringField(key, value); + } + } + + String caller = callerLocation(event); + if (caller != null) { + generator.writeStringField("logger", caller); + } + + IThrowableProxy throwable = event.getThrowableProxy(); + if (throwable != null) { + // 契约允许 error 携带完整堆栈(多行由 JSON 转义为 \n,仍是单行 JSON); + // 该字段取值逐次不同,不适合聚合,需要按错误类型聚合时用 msg + 业务字段。 + generator.writeStringField("error", ThrowableProxyUtil.asString(throwable)); + } + } + + /** + * 异步 appender(AsyncAppender)下调用点数据必须在业务线程上提前提取, + * 否则写盘线程拿到的栈已不是原始调用点。与 logstash 自带 provider 的同一处理保持一致。 + */ + @Override + public void prepareForDeferredProcessing(ILoggingEvent event) { + event.getCallerData(); + } + + /** logback 级别 → 契约小写枚举。TRACE 归入 debug(契约枚举只有 debug/info/warn/error)。 */ + private static String levelName(Level level) { + if (level == null) { + return "info"; + } + if (Level.TRACE.equals(level)) { + return "debug"; + } + return level.toString().toLowerCase(Locale.ROOT); + } + + /** 调用位置,形如 {@code ReviewSvc.java:51}(对齐 Go 侧 logger 字段的 file:line 语义)。 */ + private static String callerLocation(ILoggingEvent event) { + StackTraceElement[] callerData = event.getCallerData(); + if (callerData == null || callerData.length == 0) { + return null; + } + StackTraceElement frame = callerData[0]; + if (frame.getFileName() == null) { + return null; + } + return frame.getFileName() + ":" + frame.getLineNumber(); + } +} diff --git a/java/src/main/java/io/opensourceways/obssdk/log/ObsLogging.java b/java/src/main/java/io/opensourceways/obssdk/log/ObsLogging.java index 3a7125b..81e276c 100644 --- a/java/src/main/java/io/opensourceways/obssdk/log/ObsLogging.java +++ b/java/src/main/java/io/opensourceways/obssdk/log/ObsLogging.java @@ -11,8 +11,9 @@ * 由接入服务的 logback JSON encoder(logstash-logback-encoder,见 * {@code examples/logback-json.xml})输出为单行 JSON。 * - *

固定键:{@code service / env / instance / community / request_id / trace_id} - * (与 spec/common-fields.md、spec/log-format.md 对齐)。 + *

固定键:{@code service / env / instance / community / request_id / trace_id / span_id} + * (与 spec/common-fields.md、spec/log-format.md 对齐);{@code trace_id} / {@code span_id} + * 为二期 trace 预留位,首期恒空、不写入 MDC。 * *

community 双层注入与其它语言 SDK 一致:部署级默认来自 {@code OBS_*}/Config, * 请求级由中间件在可信判定点解析后经 {@link RequestContext#push} 写入, @@ -26,6 +27,7 @@ public final class ObsLogging { public static final String MDC_COMMUNITY = "community"; public static final String MDC_REQUEST_ID = "request_id"; public static final String MDC_TRACE_ID = "trace_id"; + public static final String MDC_SPAN_ID = "span_id"; private static volatile ObsSdkConfig cfg; @@ -56,6 +58,9 @@ public static void enrich(RequestContext request) { Optional traceId = request != null && request.traceId() != null ? Optional.of(request.traceId()) : Optional.empty(); + Optional spanId = request != null && request.spanId() != null + ? Optional.of(request.spanId()) + : Optional.empty(); String base = cfg != null ? cfg.community() : null; MDC.put(MDC_COMMUNITY, community.orElse(base != null ? base : "")); @@ -65,12 +70,16 @@ public static void enrich(RequestContext request) { if (traceId.isPresent()) { MDC.put(MDC_TRACE_ID, traceId.get()); } + if (spanId.isPresent()) { + MDC.put(MDC_SPAN_ID, spanId.get()); + } } /** 请求结束时清理请求级字段,避免线程复用串染(MDC 由框架在线程回收时兜底)。 */ public static void clearRequestScope() { MDC.remove(MDC_REQUEST_ID); MDC.remove(MDC_TRACE_ID); + MDC.remove(MDC_SPAN_ID); } private static String nvl(String v) { diff --git a/java/src/test/java/io/opensourceways/obssdk/ObsJsonProviderTest.java b/java/src/test/java/io/opensourceways/obssdk/ObsJsonProviderTest.java new file mode 100644 index 0000000..7de8c9f --- /dev/null +++ b/java/src/test/java/io/opensourceways/obssdk/ObsJsonProviderTest.java @@ -0,0 +1,202 @@ +package io.opensourceways.obssdk; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.joran.JoranConfigurator; +import io.opensourceways.obssdk.log.ObsLogging; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 直接加载接入方会拷走的 {@code examples/logback-json.xml},断言实际输出符合 + * spec/log-format.md 的契约(字段名/取值/顺序)。 + * + *

这一层必须真跑 encoder 而不是只断言 MDC —— 此前的问题正是在 MDC 之后: + * 字段名是 {@code @timestamp}/{@code message}、级别是大写、时间为本地时区纳秒, + * 且未配 {@code } 导致 throwable 被整条丢弃。 + */ +class ObsJsonProviderTest { + + /** 契约:固定毫秒精度、UTC、零偏移输出 Z。 */ + private static final Pattern TIME_PATTERN = + Pattern.compile("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"); + + private static final String[] CONTRACT_ORDER = { + "time", "level", "msg", "service", "env", "instance", "community", + "request_id", "trace_id", "span_id", "logger", "error", + }; + + private LoggerContext context; + private ByteArrayOutputStream captured; + private PrintStream originalOut; + + @BeforeEach + void setUp() throws Exception { + originalOut = System.out; + captured = new ByteArrayOutputStream(); + // ConsoleAppender 在 start() 时解析 System.out,故必须在配置前替换。 + System.setOut(new PrintStream(captured, true, StandardCharsets.UTF_8)); + + File config = new File("examples/logback-json.xml"); + assertTrue(config.isFile(), "样例配置不存在(测试工作目录应为 java/):" + config.getAbsolutePath()); + + context = (LoggerContext) LoggerFactory.getILoggerFactory(); + context.reset(); + JoranConfigurator configurator = new JoranConfigurator(); + configurator.setContext(context); + configurator.doConfigure(config); + + MDC.clear(); + ObsLogging.init(ObsSdkConfig.builder() + .service("review") + .env("test") + .instance("pod-1") + .community("openEuler") + .build()); + } + + @AfterEach + void tearDown() { + System.out.flush(); + System.setOut(originalOut); + MDC.clear(); + context.reset(); + } + + @Test + void 固定字段名与顺序符合契约_不含encoder默认字段() { + Logger log = LoggerFactory.getLogger("com.x.ReviewSvc"); + log.info("job done"); + + String json = lastLine(); + + // encoder 的默认字段名必须全部消失,改用契约名。 + assertFalse(json.contains("@timestamp"), json); + assertFalse(json.contains("@version"), json); + assertFalse(json.contains("logger_name"), json); + assertFalse(json.contains("\"message\""), json); + + assertTrue(json.contains("\"msg\":\"job done\""), json); + + // 固定字段的出现顺序与契约一致。 + int previous = -1; + for (String key : CONTRACT_ORDER) { + int index = json.indexOf("\"" + key + "\":"); + if (index < 0) { + continue; // 可选字段(trace_id/span_id/logger 等)未出现时跳过 + } + assertTrue(index > previous, "字段 " + key + " 顺序不符契约:" + json); + previous = index; + } + } + + @Test + void 时间为固定毫秒UTC且以Z结尾() { + LoggerFactory.getLogger("com.x.ReviewSvc").info("job done"); + + String json = lastLine(); + String time = fieldValue(json, "time"); + + assertTrue(TIME_PATTERN.matcher(time).matches(), "时间格式不符:" + time); + assertFalse(time.contains("+"), "应为 UTC 零偏移(Z)而非带偏移:" + time); + } + + @Test + void 级别为小写() { + Logger log = LoggerFactory.getLogger("com.x.ReviewSvc"); + log.warn("watch out"); + log.error("failed"); + + String warn = line(0); + String error = line(1); + + assertTrue(warn.contains("\"level\":\"warn\""), warn); + assertTrue(error.contains("\"level\":\"error\""), error); + assertFalse(warn.contains("WARN"), warn); + assertFalse(error.contains("ERROR"), error); + } + + @Test + void logger为调用位置而非logger名() { + LoggerFactory.getLogger("com.x.ReviewSvc").info("job done"); + + String json = lastLine(); + + assertTrue(json.contains("\"logger\":\"ObsJsonProviderTest.java:"), json); + assertFalse(json.contains("com.x.ReviewSvc"), "logger 不应是 logger 名:" + json); + } + + @Test + void 请求级字段来自MDC_未设置的预留位不出现() { + MDC.put("request_id", "req-1"); + LoggerFactory.getLogger("com.x.ReviewSvc").info("scoped"); + + String json = lastLine(); + + assertTrue(json.contains("\"service\":\"review\""), json); + assertTrue(json.contains("\"env\":\"test\""), json); + assertTrue(json.contains("\"instance\":\"pod-1\""), json); + assertTrue(json.contains("\"community\":\"openEuler\""), json); + assertTrue(json.contains("\"request_id\":\"req-1\""), json); + + // 二期预留位:未写入 MDC 时不出现在输出里。 + assertFalse(json.contains("trace_id"), json); + assertFalse(json.contains("span_id"), json); + } + + @Test + void 异常写入error字段且保留完整堆栈() { + LoggerFactory.getLogger("com.x.ReviewSvc") + .error("get account failed", new IllegalStateException("connection refused")); + + String json = lastLine(); + + assertTrue(json.contains("\"error\":\""), "throwable 被丢弃了:" + json); + assertTrue(json.contains("java.lang.IllegalStateException"), json); + assertTrue(json.contains("connection refused"), json); + // 堆栈是多行文本,JSON 转义为 \n,整条仍是单行。 + assertTrue(json.contains("\\n\\tat "), json); + assertFalse(json.contains("\n"), "输出必须是单行 JSON"); + } + + @Test + void 无异常时不输出error字段() { + LoggerFactory.getLogger("com.x.ReviewSvc").info("job done"); + + assertFalse(lastLine().contains("\"error\""), lastLine()); + } + + private String line(int index) { + String[] lines = captured.toString(StandardCharsets.UTF_8).split("\n"); + assertTrue(lines.length > index, "日志行数不足:" + captured); + return lines[index]; + } + + private String lastLine() { + String[] lines = captured.toString(StandardCharsets.UTF_8).split("\n"); + assertTrue(lines.length > 0, "没有任何日志输出"); + return lines[lines.length - 1]; + } + + /** 取出 {"key":"value"} 的 value(仅适用于单行 JSON 里的字符串字段)。 */ + private static String fieldValue(String json, String key) { + String marker = "\"" + key + "\":\""; + int start = json.indexOf(marker); + assertTrue(start >= 0, "字段不存在:" + key + " in " + json); + start += marker.length(); + int end = json.indexOf('"', start); + return json.substring(start, end); + } +} diff --git a/java/src/test/java/io/opensourceways/obssdk/ObsLoggingTest.java b/java/src/test/java/io/opensourceways/obssdk/ObsLoggingTest.java new file mode 100644 index 0000000..d98f5ae --- /dev/null +++ b/java/src/test/java/io/opensourceways/obssdk/ObsLoggingTest.java @@ -0,0 +1,66 @@ +package io.opensourceways.obssdk; + +import io.opensourceways.obssdk.context.RequestContext; +import io.opensourceways.obssdk.log.ObsLogging; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** 日志装配:请求级字段写入 MDC(由 logback JSON encoder 输出为单行 JSON)。 */ +class ObsLoggingTest { + + @BeforeEach + void setUp() { + ObsLogging.init(ObsSdkConfig.builder() + .service("review") + .env("test") + .instance("pod-1") + .community("openeuler") + .build()); + } + + @AfterEach + void tearDown() { + ObsLogging.clearRequestScope(); + RequestContext.clear(); + MDC.clear(); + } + + @Test + void enrich写入请求级字段到MDC_含span_id() { + try (RequestContext.Scope s = RequestContext.push("mindspore", "req-1", "trace-1", "span-abc")) { + ObsLogging.enrich(RequestContext.current().orElse(null)); + + assertEquals("mindspore", MDC.get(ObsLogging.MDC_COMMUNITY)); + assertEquals("req-1", MDC.get(ObsLogging.MDC_REQUEST_ID)); + assertEquals("trace-1", MDC.get(ObsLogging.MDC_TRACE_ID)); + assertEquals("span-abc", MDC.get(ObsLogging.MDC_SPAN_ID)); + } + } + + @Test + void 未提供span_id时不写入MDC() { + try (RequestContext.Scope s = RequestContext.push("openeuler", "req-1", "trace-1")) { + ObsLogging.enrich(RequestContext.current().orElse(null)); + + assertEquals("trace-1", MDC.get(ObsLogging.MDC_TRACE_ID)); + assertNull(MDC.get(ObsLogging.MDC_SPAN_ID)); + } + } + + @Test + void clearRequestScope清掉全部预留位() { + try (RequestContext.Scope s = RequestContext.push("openeuler", "req-1", "trace-1", "span-abc")) { + ObsLogging.enrich(RequestContext.current().orElse(null)); + ObsLogging.clearRequestScope(); + + assertNull(MDC.get(ObsLogging.MDC_REQUEST_ID)); + assertNull(MDC.get(ObsLogging.MDC_TRACE_ID)); + assertNull(MDC.get(ObsLogging.MDC_SPAN_ID)); + } + } +} diff --git a/java/src/test/java/io/opensourceways/obssdk/RequestContextTest.java b/java/src/test/java/io/opensourceways/obssdk/RequestContextTest.java index 6ee0336..2992033 100644 --- a/java/src/test/java/io/opensourceways/obssdk/RequestContextTest.java +++ b/java/src/test/java/io/opensourceways/obssdk/RequestContextTest.java @@ -53,4 +53,26 @@ class RequestContextTest { assertTrue(RequestContext.current().isEmpty()); assertFalse(RequestContext.communityOverride().isPresent()); } + + @Test + void span_id预留_四参push可读且进MDC快照() { + RequestContext.clear(); + assertFalse(RequestContext.currentSpanId().isPresent()); + + try (RequestContext.Scope s = RequestContext.push("openeuler", "req-1", "trace-1", "span-abc")) { + assertEquals("span-abc", RequestContext.currentSpanId().orElse(null)); + assertEquals("trace-1", RequestContext.currentTraceId().orElse(null)); + assertEquals("span-abc", + RequestContext.current().orElseThrow().asMdcFields().get("span_id")); + } + assertFalse(RequestContext.currentSpanId().isPresent()); + + // 三参重载(首期用法):span_id 缺省,不进 MDC 快照。 + try (RequestContext.Scope s = RequestContext.push("openeuler", "req-1", "trace-1")) { + assertFalse(RequestContext.currentSpanId().isPresent()); + assertFalse(RequestContext.current().orElseThrow().asMdcFields().containsKey("span_id")); + // 其它字段不受影响。 + assertEquals("req-1", RequestContext.currentRequestId().orElse(null)); + } + } } diff --git a/node/README.md b/node/README.md index d553344..4e0cdc7 100644 --- a/node/README.md +++ b/node/README.md @@ -4,7 +4,7 @@ opensourceways 微服务可观测薄封装 SDK 的 Node 实现,契约见 [spec - **日志**:`lib/log` —— 单行 JSON 写 stream(默认 stdout) - **指标**:`lib/metrics` —— prom-client 薄封装(counter/gauge/histogram) -- **请求上下文**:`lib/context` —— `AsyncLocalStorage` 承载 `community/request_id/trace_id` +- **请求上下文**:`lib/context` —— `AsyncLocalStorage` 承载 `community/request_id/trace_id/span_id`(后两者为二期 trace 预留位) - **中间件**:`lib/middleware` —— Express/通用 HTTP 中间件(注入 request_id + 可信判定点解析 community + 记 `obs_http_server_*`) - **community 双层注入**:`service/env/instance` 常驻;`community` 可变 —— 请求上下文覆盖,未覆盖回退部署默认(`OBS_*` 环境变量) @@ -36,7 +36,7 @@ const mw = makeMiddleware({ // /metrics 暴露:app.get('/metrics', metricsRouteHandler(m)); ``` -请求级覆盖(context 作用域内日志 / 指标自动带覆盖 community 与 request_id/trace_id): +请求级覆盖(context 作用域内日志 / 指标自动带覆盖 community 与 request_id/trace_id/span_id): ```js obs.context.bindRequest({ community: 'mindspore', requestId: 'req-1', traceId: 'trace-x' }, () => { diff --git a/node/lib/context.js b/node/lib/context.js index 5235f47..624bf91 100644 --- a/node/lib/context.js +++ b/node/lib/context.js @@ -26,16 +26,22 @@ function traceId() { return current().traceId || null; } -// bindRequest(store, fn):在 store({community?, requestId?, traceId?})内执行 fn。 +function spanId() { + return current().spanId || null; +} + +// bindRequest(store, fn):在 store({community?, requestId?, traceId?, spanId?})内执行 fn。 // 会与已有上下文合并(缺省字段继承外层)。 +// traceId / spanId 为二期 trace 预留注入位,首期恒空、有值才输出。 function bindRequest(fields, fn) { const prev = storage.getStore() || {}; const merged = { community: fields.community !== undefined ? fields.community : prev.community, requestId: fields.requestId !== undefined ? fields.requestId : prev.requestId, traceId: fields.traceId !== undefined ? fields.traceId : prev.traceId, + spanId: fields.spanId !== undefined ? fields.spanId : prev.spanId, }; return storage.run(merged, fn); } -module.exports = { current, community, requestId, traceId, bindRequest }; +module.exports = { current, community, requestId, traceId, spanId, bindRequest }; diff --git a/node/lib/log.js b/node/lib/log.js index 108761c..11dc8bb 100644 --- a/node/lib/log.js +++ b/node/lib/log.js @@ -44,6 +44,7 @@ function log(levelName, msg, fields) { }); if (req.requestId) record.request_id = req.requestId; if (req.traceId) record.trace_id = req.traceId; + if (req.spanId) record.span_id = req.spanId; const line = JSON.stringify(record); stream.write(line + '\n'); diff --git a/node/test/log.test.js b/node/test/log.test.js index 037fa09..e8ab473 100644 --- a/node/test/log.test.js +++ b/node/test/log.test.js @@ -35,7 +35,10 @@ test('静态字段注入 + community 双层注入', () => { assert.strictEqual(a.level, 'info'); assert.strictEqual(a.msg, 'hello'); assert.strictEqual(a.event, 'pr'); + // 无请求上下文时预留字段不出现。 assert.ok(!('request_id' in a)); + assert.ok(!('trace_id' in a)); + assert.ok(!('span_id' in a)); const b = out[1]; assert.strictEqual(b.community, 'mindspore'); @@ -57,4 +60,24 @@ test('level 过滤 + trace_id 预留注入', () => { assert.strictEqual(out[0].msg, 'kept'); assert.strictEqual(out[1].trace_id, 'trace-xyz'); assert.strictEqual(out[1].community, 'openeuler'); + // 未 bind spanId 时不输出该键。 + assert.ok(!('span_id' in out[1])); +}); + +test('span_id 预留注入(与 trace_id 互不干扰)', () => { + const { stream, lines } = capture(); + log.init({ service: 'srv', community: 'openeuler', stream }); + + context.bindRequest({ traceId: 'trace-xyz', spanId: 'span-abc' }, () => { + log.info('with span'); + }); + context.bindRequest({ spanId: 'span-only' }, () => { + log.info('span only'); + }); + + const out = lines(); + assert.strictEqual(out[0].span_id, 'span-abc'); + assert.strictEqual(out[0].trace_id, 'trace-xyz'); + assert.strictEqual(out[1].span_id, 'span-only'); + assert.ok(!('trace_id' in out[1])); }); diff --git a/python/README.md b/python/README.md index 0fbf73e..6ba0a98 100644 --- a/python/README.md +++ b/python/README.md @@ -4,7 +4,7 @@ opensourceways 微服务可观测薄封装 SDK 的 Python 实现,契约见 [sp - **日志**:`obs_sdk.log` —— 结构化 JSON(root logger 挂唯一 JsonHandler,字段规范见 spec/log-format.md) - **指标**:`obs_sdk.metrics` —— prometheus-client 薄封装,自带独立 CollectorRegistry -- **请求上下文**:`obs_sdk._context` —— `contextvars` 承载 `community/request_id/trace_id` +- **请求上下文**:`obs_sdk._context` —— `contextvars` 承载 `community/request_id/trace_id/span_id`(后两者为二期 trace 预留位) - **框架适配**:`obs_sdk.middleware` —— FastAPI / Flask / Django 中间件(注入 request_id + 可信判定点解析 community) - **community 双层注入**:`service/env/instance` 常驻 const;`community` 可变 —— 请求上下文覆盖(`_context.bind`),未覆盖回退部署默认(`OBS_*` 环境变量) diff --git a/python/obs_sdk/_context.py b/python/obs_sdk/_context.py index c3847a4..08bcc07 100644 --- a/python/obs_sdk/_context.py +++ b/python/obs_sdk/_context.py @@ -1,9 +1,10 @@ """请求级通用字段的 context 读写(Python 版 sdkctx)。 用 contextvars 实现(线程 / async 各自隔离,与 spec/common-fields.md 一致): - - community 覆盖值 / request_id / trace_id 存于当前 Context; + - community 覆盖值 / request_id / trace_id / span_id 存于当前 Context; - 框架适配器在入口把可信解析出的字段 bind 进 Context,退出时 reset; - - log / metrics 读取当前 Context,未绑定则回退部署级默认。 + - log / metrics 读取当前 Context,未绑定则回退部署级默认; + - trace_id / span_id 为二期 trace 预留注入位,首期恒空、有值才输出。 """ from __future__ import annotations @@ -21,6 +22,7 @@ class Request: community: str | None = None request_id: str | None = None trace_id: str | None = None + span_id: str | None = None _current: contextvars.ContextVar[Request] = contextvars.ContextVar( @@ -46,9 +48,14 @@ def trace_id() -> str | None: return _current.get().trace_id +def span_id() -> str | None: + """当前 span_id(二期 trace 预留,首期恒为 None)。""" + return _current.get().span_id + + @contextmanager def bind(*, community: str | None = None, request_id: str | None = None, - trace_id: str | None = None) -> Iterator[None]: + trace_id: str | None = None, span_id: str | None = None) -> Iterator[None]: """把请求级字段 bind 进当前 Context;退出自动 reset。 用于框架适配器入口 / 业务可信判定点: @@ -61,6 +68,7 @@ def bind(*, community: str | None = None, request_id: str | None = None, community=community if community is not None else prev.community, request_id=request_id if request_id is not None else prev.request_id, trace_id=trace_id if trace_id is not None else prev.trace_id, + span_id=span_id if span_id is not None else prev.span_id, ) token = _current.set(merged) try: diff --git a/python/obs_sdk/log.py b/python/obs_sdk/log.py index 07f6cd3..eec9ffe 100644 --- a/python/obs_sdk/log.py +++ b/python/obs_sdk/log.py @@ -3,8 +3,8 @@ 格式遵循 spec/log-format.md: - 单行 JSON,经 stdout 进 LTS; - 常驻字段 service/env/instance/community 在 init 时注入; - - 请求级 community 覆盖 / request_id / trace_id 从 _context 读取; - - trace_id 预留位(有值才输出,二期经 _context.bind(trace_id=...) 注入)。 + - 请求级 community 覆盖 / request_id / trace_id / span_id 从 _context 读取; + - trace_id / span_id 预留位(有值才输出,二期经 _context.bind(trace_id=..., span_id=...) 注入)。 用法: @@ -67,6 +67,8 @@ def format(self, record: logging.LogRecord) -> str: fields["request_id"] = req.request_id if req.trace_id: fields["trace_id"] = req.trace_id + if req.span_id: + fields["span_id"] = req.span_id # 常驻字段最后写入 → 覆盖同名 extra,保证统一。 fields.update({ diff --git a/python/tests/test_log.py b/python/tests/test_log.py index f70d90f..f15b177 100644 --- a/python/tests/test_log.py +++ b/python/tests/test_log.py @@ -1,4 +1,4 @@ -"""log 模块单测:静态字段注入、community 双层注入、level 过滤、trace_id 预留。""" +"""log 模块单测:静态字段注入、community 双层注入、level 过滤、trace_id/span_id 预留。""" import io import json @@ -51,9 +51,10 @@ def test_static_fields_injected(): assert f["level"] == "info" assert f["msg"] == "hello" assert f["event"] == "pull_request" - # 无请求上下文时不输出 request_id / trace_id。 + # 无请求上下文时不输出 request_id / trace_id / span_id。 assert "request_id" not in f assert "trace_id" not in f + assert "span_id" not in f def test_business_extra_overrides_nothing_common(): @@ -96,6 +97,26 @@ def test_trace_id_reserved_inject(): f = _lines(buf)[0] assert f["trace_id"] == "trace-xyz" assert f["community"] == "openeuler" + # 未 bind span_id 时不输出该键。 + assert "span_id" not in f + + +def test_span_id_reserved_inject(): + from obs_sdk import _context + buf = _capture(community="openeuler") + logger = log.get_logger("t") + with _context.bind(trace_id="trace-xyz", span_id="span-abc"): + logger.info("with span") + f = _lines(buf)[0] + assert f["span_id"] == "span-abc" + assert f["trace_id"] == "trace-xyz" + # span_id 与 trace_id 互不干扰:只 bind span_id 时 trace_id 不出现。 + buf2 = _capture(community="openeuler") + with _context.bind(span_id="span-only"): + log.get_logger("t").info("span only") + f2 = _lines(buf2)[0] + assert f2["span_id"] == "span-only" + assert "trace_id" not in f2 def test_error_field_on_exception(): diff --git a/spec/README.md b/spec/README.md index cf20394..21eabfc 100644 --- a/spec/README.md +++ b/spec/README.md @@ -14,22 +14,23 @@ | 文件 | 内容 | | --- | --- | -| [log-format.md](log-format.md) | 结构化日志 JSON 格式、级别、通用字段、`trace_id` 预留位 | +| [log-format.md](log-format.md) | 结构化日志 JSON 格式、级别、通用字段、kv 传参约定、`trace_id` / `span_id` 预留位 | | [metrics-format.md](metrics-format.md) | 指标命名前缀、label 规范、`community` 双层注入建模 | -| [common-fields.md](common-fields.md) | 通用字段 `service` / `env` / `instance` / `community` / `request_id` / `trace_id` 的来源与注入规则 | -| [community-values.md](community-values.md) | `community` 取值枚举(service.md 各社区段,随 service.md 更新) | +| [common-fields.md](common-fields.md) | 通用字段 `service` / `env` / `instance` / `community` / `request_id` / `trace_id` / `span_id` 的来源与注入规则 | +| [community-values.md](community-values.md) | `community` 取值枚举(`infrastructure` 仓 `service.yaml` 的 communities,随 service.yaml 更新) | ## 语言 SDK 对应关系 | 语言目录 | SDK | log 底层 | metrics 底层 | 用法文档 | | --- | --- | --- | --- | --- | -| `go/` | obs-sdk-go | stdlib `encoding/json`(加锁单行 JSON → stdout) | `client_golang` | [go/README](../go/README.md) | +| `go/` | obs-sdk-go | stdlib `log/slog` + 自定义 Handler(单行扁平 JSON → stdout) | `client_golang` | [go/README](../go/README.md) | | `python/` | obs-sdk-python | stdlib `logging`(自定 JSON Formatter) | `prometheus-client` | [python/README](../python/README.md) | | `java/` | obs-sdk-java | `logback` + logstash JSON encoder | `micrometer` + prometheus registry | [java/README](../java/README.md) | | `node/` | obs-sdk-node | 自定 JSON serializer(console → stdout) | `prom-client` | [node/README](../node/README.md) | ## community 取值要点(详见 [community-values.md](community-values.md)) -- 遵循 `opensourceways/infra-common` 的 `service.md` 社区枚举:`Ascend / BoostKit / CANN / HPCKit / MindSpore / openEuler / OpenFuyao / openGauss / OpenJiuwen / OpenUBMC / OpenPangu / HiFloat / UnifiedBus / Infrastructure / openLookeng / Xihe` 等。 +- 遵循 `opensourceways/infrastructure` 的 `service.yaml` 社区枚举:`Ascend / BoostKit / CANN / Common / HiFloat / HPCKit / Infrastructure / Merlin / MindSpore / openEuler / OpenFuyao / openGauss / OpenJiuwen / openLookeng / OpenPangu / OpenUBMC / UnifiedBus / Xihe`。 +- 完整列表与重新同步方式见 [community-values.md](community-values.md)。 - 单社区独立部署的服务:`community` = 部署级静态值(环境变量 `OBS_COMMUNITY` 或 Init 配置),日志常驻字段、指标常驻 label。 - 中心化单实例服务多社区:`community` 由请求上下文动态覆盖(路由按社区分发时设置),日志按请求打印、指标按请求打点。 diff --git a/spec/common-fields.md b/spec/common-fields.md index f7de44b..97c7f8e 100644 --- a/spec/common-fields.md +++ b/spec/common-fields.md @@ -1,6 +1,6 @@ -# 通用字段契约 — service / env / instance / community / request_id / trace_id +# 通用字段契约 — service / env / instance / community / request_id / trace_id / span_id -> 6 个通用字段是所有 log 与 metrics 的公共标识维度。字段语义、来源、注入规则在此统一。 +> 7 个通用字段是所有 log 与 metrics 的公共标识维度。字段语义、来源、注入规则在此统一。 ## 字段总表 @@ -12,6 +12,7 @@ | `community` | 社区标识 | string | Init(进程级默认) **+** 请求上下文(覆盖) | 双态 | | `request_id` | 单请求关联 ID | string | 请求上下文 | 动态 | | `trace_id` | 分布式 trace ID(二期接入) | string | 请求上下文(预留) | 动态/预留 | +| `span_id` | 分布式 trace 内的 span 标识(二期接入) | string | 请求上下文(预留) | 动态/预留 | ## 静态字段来源与默认值 @@ -61,7 +62,9 @@ - SDK 中间件:入口中间件若上下文无 request_id 则生成并写入;日志绑定上下文时带上。 - 出站调用传播:作为头/字段传给下游服务(语言 SDK 提供 outbound 侧 helper),保证全链路同 ID。 -## trace_id 预留位 +## trace_id / span_id 预留位 -- 二期接 OpenTelemetry 后,trace 经 context 注入;日志上下文里的 `trace_id` 即取自该 context。 -- 首期:`trace_id` 注入位必须存在(log 上下文 API 有对应字段位置、metrics label 有对应位但可省略),值恒空。目标:二期 trace 接入时**零日志格式返工**。 +- 二期接 OpenTelemetry 后,trace / span 经 context 注入;日志上下文里的 `trace_id`、`span_id` 即取自该 context。 +- 首期:两个注入位必须存在(log 上下文 API 有对应字段位置、metrics label 有对应位但可省略),值恒空。 + 目标:二期 trace 接入时**零日志格式返工**。 +- 两者都是**高基数**值(每请求/每 span 唯一),只入日志,**禁止作 metrics label**(见 metrics-format.md)。 diff --git a/spec/community-values.md b/spec/community-values.md index fb2abed..a8206a0 100644 --- a/spec/community-values.md +++ b/spec/community-values.md @@ -1,13 +1,24 @@ # community 取值枚举 -> 权威来源:`opensourceways/infra-common` 仓 [`service.md`](https://github.com/opensourceways/infra-common/blob/master/service.md) 的**社区段**(section)。service.md 新增/改名社区时,本文件随服务接入同步更新。 -> `community` 字段/label 取值**必须落在此枚举内**(小写用连字符的取值为部署时约定别名,见下)。 +> 权威来源:`opensourceways/infrastructure` 仓 [`service.yaml`](https://github.com/opensourceways/infrastructure/blob/main/service.yaml) +> 顶层的 `communities` 列表。service.yaml 新增/改名社区时,本文件随服务接入同步更新。 +> `community` 字段/label 取值**必须落在此枚举内**(log/label 里的取值统一转小写,见下)。 -## 取值(跟随 service.md 常用社区段) +## 取值(跟随 service.yaml 的 communities 列表) -`Ascend` · `BoostKit` · `CANN` · `HPCKit` · `MindSpore` · `openEuler` · `OpenFuyao` · `openGauss` · `OpenJiuwen` · `OpenUBMC` · `OpenPangu` · `HiFloat` · `UnifiedBus` · `Infrastructure` · `openLookeng` · `Xihe` +`Ascend` · `BoostKit` · `CANN` · `Common` · `HiFloat` · `HPCKit` · `Infrastructure` · `Merlin` · `MindSpore` · `openEuler` · `OpenFuyao` · `openGauss` · `OpenJiuwen` · `openLookeng` · `OpenPangu` · `OpenUBMC` · `UnifiedBus` · `Xihe` -> 上表大小写跟随 service.md 原样。**通用约定:log/label 里统一转小写**以保跨服务可聚合(查询聚合大小写敏感)。例:service.md `Ascend` → 字段值 `ascend`;`openEuler` → `openeuler`;`MindSpore` → `mindspore`。 +> 上表大小写跟随 service.yaml 原样。**通用约定:log/label 里统一转小写**以保跨服务可聚合(查询聚合大小写敏感)。例:service.yaml `Ascend` → 字段值 `ascend`;`openEuler` → `openeuler`;`MindSpore` → `mindspore`。 + +### 如何重新同步 + +service.yaml 有上万行,社区名在 `communities:` 段内以零缩进的 `- name:` 出现(其下 `services:` 里缩进两级的 `- name:` 是**服务名**,别取错)。重新提取: + +```bash +gh api repos/opensourceways/infrastructure/contents/service.yaml \ + -H "Accept: application/vnd.github.raw" \ + | awk '/^communities:/{f=1;next} /^[a-z_]+:/{f=0} f&&/^- name: /{sub(/^- name: /,"");print}' +``` ## 多社区 / 中心化部署 diff --git a/spec/log-format.md b/spec/log-format.md index 702591c..f77e120 100644 --- a/spec/log-format.md +++ b/spec/log-format.md @@ -7,7 +7,8 @@ - 每行一条日志,单行 JSON(**不 pretty**,不换行),通过 stdout 输出。 - 字符集 UTF-8。 -- 时间字段 UTC,RFC 3339 / ISO-8601,带毫秒以上精度:`2026-09-08T07:12:34.567Z` 或 `.567890Z`(尽力而为,纳秒精度佳)。 +- 时间字段 UTC,RFC 3339 / ISO-8601,**固定毫秒精度(3 位小数)**:`2026-09-08T07:12:34.567Z`。 + 固定位数(而非纳秒可变位数)是为了四语言输出形态一致、LTS 侧正则提取稳定;秒级时间戳已足够支撑检索与告警,纳秒对日志场景无实际增益。 ## 顶层字段 @@ -16,17 +17,21 @@ | `time` | string | 是 | RFC 3339 UTC,输出时刻 | | `level` | string | 是 | 枚举见下 | | `msg` | string | 是 | 人类可读消息(错误场景为该错误摘要) | -| `service` | string | 是 | 服务名(= service.md 微服务名 / go module 子目录名),Init 时注入,常驻 | +| `service` | string | 是 | 服务名(= `infrastructure` 仓 `service.yaml` 的服务名 / go module 子目录名),Init 时注入,常驻 | | `env` | string | 是 | 部署环境 `prod/test/preview/staging`,Init 注入,常驻 | | `instance` | string | 是 | 实例标识(k8s pod 名/IP),Init 注入,常驻 | | `community` | string | 是 | 社区标识。部署级默认注入;请求级覆盖见 common-fields.md | | `request_id` | string | 否* | 单请求关联 ID;无则输出空串或省略(*见下) | | `trace_id` | string | 否* | **预留位**:二期 trace 接入前始终为空/省略,见下 | -| `logger` | string | 否 | 产生日志的 logger/包名(调试定位用) | -| `error` | string/object | 否 | 错误信息(仅 error 级推荐) | +| `span_id` | string | 否* | **预留位**:二期 trace 接入前始终为空/省略,见下 | +| `logger` | string | 否 | 产生日志的调用位置(形如 `service/webhook.go:51`,调试定位用) | +| `error` | string | 否 | 错误信息。Go 等无异常上下文的语言输出 `err.Error()` 原因链文本(低基数、可检索);Python 等有异常上下文的语言**可含完整 traceback**(多行,JSON 转义为 `\n`,仍是单行 JSON)。仅在携带错误对象/异常时输出 | | 其余 | — | 否 | 业务字段平铺在顶层(扁平 JSON),键名小写下划线(`snake_case`),禁止嵌套对象优先平铺 | -> **request_id / trace_id 预留约定**:契约字段表里两者语义必留,但取值可为空。两种实现都接受:(a) 输出 `"request_id":""` / `"trace_id":""` 空串;(b) 为空时**省略该键**。SDK 内部统一为「有值才输出」可减少噪音——但 `trace_id` 语义位必须存在(SDK 须有注入该字段的能力),二期 trace 一接即可见。 +> **request_id / trace_id / span_id 预留约定**:契约字段表里三者语义必留,但取值可为空。两种实现都接受:(a) 输出 `"trace_id":""` / `"span_id":""` 空串;(b) 为空时**省略该键**。Go SDK 统一采用 (b)「有值才输出」以减少噪音——但 `trace_id`/`span_id` 语义位必须存在(SDK 须有注入这两个字段的能力),二期 trace 一接即可见。 + +> **error 字段不适合聚合**:带 traceback 的语言(Python 等)该字段取值逐次不同,不要对它做 count / group by。 +> 需要按错误类型聚合时,用 `msg`(常量短语)+ 业务字段(如 `error_code` / `event`)另作维度。 ## level 枚举 @@ -34,18 +39,40 @@ ## 示例(合法行) +首期(trace_id / span_id 为空 → 省略该键): + +```json +{"time":"2026-09-08T07:12:34.567Z","level":"info","msg":"handle github webhook","service":"robot-universal-review","env":"test","instance":"review-7f9c5d8b66-abcde","community":"openEuler","request_id":"req_01J8XK","logger":"service/webhook.go:51","event":"pull_request","action":"opened"} +``` + +二期(trace 接入后,同一条日志格式零返工即带 trace_id / span_id): + ```json -{"time":"2026-09-08T07:12:34.567890Z","level":"info","msg":"handle github webhook","service":"robot-universal-review","env":"test","instance":"review-7f9c5d8b66-abcde","community":"openEuler","request_id":"req_01J8XK","trace_id":"","event":"pull_request","action":"opened"} +{"time":"2026-09-08T07:12:34.567Z","level":"error","msg":"get account failed","service":"robot-universal-review","env":"test","instance":"review-7f9c5d8b66-abcde","community":"openEuler","request_id":"req_01J8XK","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7","logger":"service/todo.go:51","error":"get account from db: connection refused","user_id":"u-123"} ``` +## 字段传参约定:msg 常量 + kv 承载数据 + +`msg` 是**人类可读的常量短语**(如 `"get account failed"`),不得把随请求变化的取值(用户 ID、订单号、错误详情)格式化进 `msg`。 +所有可查询的维度一律通过 **kv** 传入,平铺为顶层字段: + +```go +log.ErrorContext(ctx, "get account failed", "user_id", uid, "error", err) +// → {"msg":"get account failed","user_id":"u-123","error":"get account from db: connection refused"} +``` + +理由:`msg` 一旦嵌值,同一事件的每行 `msg` 都不同 → LTS 无法按 `msg` 聚合/计数,日志告警规则失效;且改文案即静默破坏已配置的检索。 + +**禁止 printf 风格**(`log.Errorf("failed for user %s", uid)`):Go `log/slog` 无 printf 变体,四语言 SDK **只提供 kv 形式**,与主流生态(stdlib `log/slog`、kratos v3)一致。 + ## 推荐:从上下文附加请求级字段 -所有语言 SDK 都应支持从请求上下文(Go `context.Context` 等价物)读取并附加 `request_id`、`community`、`trace_id`(预留)三个字段。SDK 暴露两种调用风格之一或兼具: +所有语言 SDK 都应支持从请求上下文(Go `context.Context` 等价物)读取并附加 `request_id`、`community`、`trace_id`、`span_id`(后两者预留)四个字段。调用风格二选一或兼具: -1. **绑定式**:`logger.WithRequestContext(ctx).Info(...)` —— 用 context 值绑定一个带请求字段的 logger,此后调用自动带上。 -2. **参数式**:`logger.Info(ctx, msg, kv...)` —— 每次显式传 context。 +1. **参数式**:`InfoContext(ctx, msg, kv...)` —— 每次显式传 context。Go SDK 采用此式,形状对齐 kratos v3 / stdlib `log/slog`。 +2. **绑定式**:`WithRequestContext(ctx)` 返回绑定请求字段的 logger —— 此后调用自动带上。 -要求:**同一条日志内 `request_id`/`community`/`trace_id` 必须唯一、确定**;context 缺省时回退到 Init 注入的静态默认值(community 尤其如此)。 +要求:**同一条日志内 `request_id`/`community`/`trace_id`/`span_id` 必须唯一、确定**;context 缺省时回退到 Init 注入的静态默认值(community 尤其如此)。 ## LTS 检索约定 diff --git a/spec/metrics-format.md b/spec/metrics-format.md index 01ef473..46abcc7 100644 --- a/spec/metrics-format.md +++ b/spec/metrics-format.md @@ -24,7 +24,7 @@ SDK 统一为所有注册的指标自动附加以下 **const label**(值来自 | `community` | Init 默认 **+ 请求级覆盖** | 见下节——唯一一个可动态的公共 label | > 其余业务维度 label(`endpoint` / `method` / `status` / `code` 等)由业务/中间件按需声明。 -> **禁止**把 `request_id` 等高基数值当 label——会撑爆时序基数。 +> **禁止**把 `request_id` / `trace_id` / `span_id` 等高基数值当 label——会撑爆时序基数。 ## community 双层注入在指标上的实现