日志与可观测性
本页说明 wicket v0.1.4 的日志从哪里来、哪些能由宿主控制、哪些不能,以及在没有内置事件系统的情况下,宿主如何自己记录协议请求的结果。
先给结论:
| 你想做的事 | v0.1.4 的实际情况 |
|---|---|
用 server.WithLogger(l) 指定 logger | 生效,但只作用于少数几个中间件和启动校验 |
用 cfg.SetLogger(l) 指定 logger | 不生效:调用不会报错,但输出不会变 |
| 修改协议端点、校验器、存储适配器的日志级别或格式 | 做不到:它们固定使用 server.DefaultLogger(),即 JSON、写 stdout、Debug 级、带源码位置 |
| 订阅“令牌已签发”“登录失败”之类的事件 | 没有内置事件系统,需要宿主在中间件里自己记录(见下文) |
两个 logger,各管一摊
wicket 的日志都通过标准库 *slog.Logger 输出,但进程里实际存在两个互不相干的 logger。
容器里的 logger:server.DefaultLogger()
di.NewRoot() 在构造容器时,就会立即创建一个单例 *slog.Logger,所有由容器创建的组件都用它:端点路由、各协议端点 handler、请求校验器、令牌与发现文档生成器、存储适配器,以及“未注入存储、退回内存实现”这类告警。
这个单例的取值逻辑是:如果 ServerConfig 里已经设置了 logger 就用它,否则用 server.DefaultLogger()。问题在于取值发生在 di.NewRoot() 内部,而你只有拿到 root 之后才能调用 cfg.SetLogger,这时单例早已用默认值建好,之后不会再读取。因此在 v0.1.4 中:
cfg.SetLogger(...)对容器内的组件没有效果;- 容器内组件的日志一律按
server.DefaultLogger()输出:
// server.DefaultLogger 的实际定义(v0.1.4)
slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelDebug,
}))也就是说:格式是 JSON,目的地是进程的 stdout,级别是 Debug(每个请求都会产生多行 Debug 日志),每行都带 source 字段(函数名、源文件路径和行号)。
宿主无法改写这一路日志
v0.1.4 没有公开入口可以替换这个 logger,也无法调整它的级别或格式。在 di.NewRoot() 之前或之后调用 slog.SetDefault 也不会影响它,因为它不读取全局默认 logger。生产环境请在日志采集侧处理,见文末“生产建议”。
中间件的 logger:server.WithLogger
server.DefaultServer(root, server.WithLogger(l)) 传入的 logger 只交给 srv.UseWicket() 自己组装的那几层,实际会写日志的有:
- 启动时的一行 Info(
Starting wicket authorization server ...),以及启动校验发现缺少必需服务时的错误; - 默认认证方案中间件:认证 handler 解析失败、认证失败、请求处理器出错时的 Warn,字段为
scheme、error; - 动态认证方案中间件(仅在启用动态方案时):激活提示与降级告警。
不传 WithLogger 时,这一路同样退回 server.DefaultLogger()。
推荐写法:两处都传同一个 logger。WithLogger 现在就生效;SetLogger 在 v0.1.4 中无效但无害,保留它能让装配代码表达清楚的意图。
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
root, err := di.NewRoot()
// ...
cfg := root.ServerConfig()
cfg.SetLogger(logger) // v0.1.4:不改变容器内组件的输出
// ... 其余装配,cfg.Freeze()
srv, err := server.DefaultServer(root, server.WithLogger(logger)) // 生效:中间件与启动校验实测(快速开始示例):SetLogger 与 WithLogger 都传入写 stderr 的 TextHandler,stderr 上只出现 WithLogger 那一路的启动行;所有端点处理过程的日志仍以 JSON 写到 stdout。这就是“传了 TextHandler 输出却还是 JSON”的原因。
日志长什么样
容器内组件的一行典型日志(source 已省略):
{"time":"...","level":"DEBUG","msg":"Request path matched to endpoint type","path":"/connect/token","endpoint":"Token"}
{"time":"...","level":"ERROR","msg":"No validator is registered for the grant type","grantType":"urn:example:grant-type:sms"}
{"time":"...","level":"INFO","msg":"User authentication failed","error":"invalid_grant","errorDescription":"invalid_username_or_password"}常见字段:
| 字段 | 含义 |
|---|---|
time、level、msg、source | slog 标准字段;source 来自 AddSource: true |
path、endpoint | 请求路径与匹配到的端点名(Token、Discovery、Authorize 等) |
clientId | 客户端标识,出现在客户端校验、令牌请求、内省等环节 |
subjectId、sessionId | 用户标识与会话标识 |
error、err、errorDescription | 协议错误码或 Go 错误文本 |
scopes、grantType、type、validatorType | 请求的作用域、授权类型、参与处理的组件类型 |
几点需要知道:
- 部分
msg里带有未替换的占位符,例如client configuration validation for client {clientId} succeeded.,真实值在同名字段里。检索时请按字段而不是按msg全文匹配。 - 日志级别的语义偏“开发调试”:客户端没带
client_id、请求体不是表单之类的调用方错误也会记为ERROR。不要把 wicket 的ERROR数量直接当作服务故障告警的指标。
凭据与敏感值
wicket 自身对日志有一条明确约束,并有端到端测试守护:存储适配器在处理带凭据的数据时,日志只写标识符和错误文本。具体来说:
- 客户端配置校验失败时,日志里有
clientId,但不会出现客户端密钥; - 授权记录(持久化授权)的载荷不会进入日志;
- “未注入存储、退回内存实现”的告警会写到 wicket 使用的 logger,并点名是哪个存储,而不是由组件私自另建 logger 输出。
此外,phoneverify、wechatverify 的配置类型实现了 slog.LogValuer,即使宿主把整个配置对象交给 logger,AccessKeySecret 等密钥字段也会被遮蔽。
但上述约束不覆盖所有日志点。v0.1.4 的部分日志(包括 ERROR 级与 DEBUG 级)可能包含敏感的请求参数与客户端配置。由于宿主目前无法调整容器内日志器的级别(见上文),“把级别调到 Warn 以上”在 v0.1.4 中做不到,也不足以解决问题;请在日志采集侧过滤,具体做法见文末“生产建议”。修复版本发布后本页会同步更新。
已验证不会出现在日志里的:客户端在令牌请求中提交的明文 client_secret、password 授权中的用户密码、签发出的访问令牌。
宿主自己写日志时的原则
只记录标识符(client_id、sub、端点路径、状态码),不要记录:访问令牌、刷新令牌、ID 令牌、授权码、code_verifier、设备码与用户码、CIBA 的 auth_req_id、客户端密钥、签名私钥,以及原始请求体。需要关联同一枚令牌时,记录它的 jti 或哈希,而不是令牌本身。
没有内置事件系统时,如何记录协议结果
wicket v0.1.4 没有事件或审计事件服务:选项中预留的事件配置项在代码里处于注释状态,也没有任何可订阅的事件接口。如果你需要“谁在什么时候从哪个端点拿到了什么结果”的审计线索,可行的做法是在宿主中间件链的最外层包一层,记录每个协议请求的方法、路径、状态码和耗时。
下面的中间件已在快速开始示例的基础上编译并运行验证:
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// accessLog 记录发往 wicket 协议端点的请求结果(不读取请求体,不记录任何参数值)。
func accessLog(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/connect/") && !strings.HasPrefix(r.URL.Path, "/.well-known/") {
next.ServeHTTP(w, r)
return
}
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
attrs := []any{
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", rec.status),
slog.Duration("duration", time.Since(start)),
}
// Basic 认证头里只取 client_id,绝不记录密码部分
if id, _, ok := r.BasicAuth(); ok {
attrs = append(attrs, slog.String("client_id", id))
}
level := slog.LevelInfo
if rec.status >= 400 {
level = slog.LevelWarn
}
logger.Log(r.Context(), level, "wicket protocol request", attrs...)
})
}
// 装配:放在 wicket 中间件之外
handler := accessLog(logger, hosting.ScopeMiddleware(root)(srv.UseWicket()(app)))实测输出(TextHandler):
level=INFO msg="wicket protocol request" method=POST path=/connect/token status=200 duration=1.57ms client_id=billing-worker
level=INFO msg="wicket protocol request" method=POST path=/connect/introspect status=200 duration=253µs client_id=orders
level=INFO msg="wicket protocol request" method=GET path=/connect/authorize status=302 duration=542µs这种做法的边界:
- 能拿到的是 HTTP 层结果。令牌端点的失败一律是 4xx,具体的 OAuth 错误码(
invalid_grant、invalid_client等)在响应体里;要区分它们,需要在包装的ResponseWriter中额外缓存令牌端点的响应体并只解析error字段,不要把整段响应体写进日志(成功响应里就是令牌)。 - 客户端如果把
client_id放在表单里而不是 Basic 认证头,中间件拿不到它,除非你自己解析表单——那会消费请求体,需要再还原,不建议在日志中间件里做。 - 用户维度的审计(谁登录了、用了哪种方式)更适合放在宿主自己的登录页逻辑里记录,那是宿主掌握用户身份的地方。
生产建议
- 在采集侧按级别过滤 stdout。 容器内组件固定以 Debug 级输出 JSON 到 stdout,每个请求都会产生多行日志。由日志采集器(按
level字段)丢弃DEBUG,只保留INFO及以上;同时注意ERROR里含有调用方错误,告警阈值要单独设计。 - stdout 只留给 wicket。 宿主自己的日志写到 stderr 或独立目的地,便于采集侧分流和设置不同的保留策略。
- 按敏感数据管理 wicket 的日志。 由于部分日志可能包含敏感请求参数,请限制访问权限、缩短保留期,避免把原始日志发往不受信任的系统;并在采集管道中删除
request、client、code、codeVerifier这几个字段。 source字段会暴露构建机上的模块路径。 如果日志会流向不受信任的位置,在采集侧去掉它。- 宿主中间件统一传
server.WithLogger。 不传时这一路也会以 Debug 级写 stdout。 - 审计需求由宿主实现。 用上文的访问日志中间件,加上宿主登录逻辑里的用户级记录。