cosmossdk.io/log/v2 是 Cosmos SDK 的日志包。 从高层看,需要理解三个部分:
  1. log.NewLogger(...) 用于创建默认的 Cosmos SDK logger。它基于 zerolog。
  2. cosmossdk.io/log/v2/slog 让你可以用标准库的 *slog.Logger 来满足同一个 SDK Logger 接口。
  3. log.NewMultiLogger(...) 会把一次日志调用分发到多个 SDK logger。SDK 在服务启动且启用了 OpenTelemetry 日志导出时会使用它。想了解我们如何支持 OpenTelemetry,请阅读遥测文档。
如果你只需要普通的 SDK 日志能力,通常只需要 log.NewLogger,它会被自动创建并设置到 sdk.Context 上。

默认 Logger

默认实现是对 zerolog 的一个轻量封装。
logger := log.NewLogger(os.Stderr)

logger.Info("starting app", "chain_id", chainID)
logger.Error("failed to load state", "err", err)
NewLogger 默认会输出人类可读的控制台日志。服务命令的组装会根据 CLI 配置切换选项,例如:
  • OutputJSONOption():用于输出 JSON 日志
  • LevelOption(...):用于设置全局日志级别
  • FilterOption(...):用于按模块过滤
  • TraceOption(true):用于在错误日志中包含堆栈追踪
  • VerboseLevelOption(...):用于临时详细模式
SDK 也会一致地使用 module 字段。这个包为此暴露了 log.ModuleKey:
logger = logger.With(log.ModuleKey, "bank")
logger.Info("send coins", "from", from, "to", to)
这很重要,因为日志过滤实现会在解析诸如 consensus:debug,*:error 这样的值时依赖 module 字段。

结构化上下文

Logger.With(...) 会返回一个带有额外字段的派生 logger:
keeperLogger := logger.With(log.ModuleKey, "staking", "component", "keeper")
keeperLogger.Info("validator updated", "operator", valAddr)
这是给 logger 实例附加稳定元数据的常规方式。

感知 Context 的日志

v2 的 Logger 接口增加了 *Context 方法:
type Logger interface {
	Info(msg string, keyVals ...any)
	InfoContext(ctx context.Context, msg string, keyVals ...any)
	Warn(msg string, keyVals ...any)
	WarnContext(ctx context.Context, msg string, keyVals ...any)
	Error(msg string, keyVals ...any)
	ErrorContext(ctx context.Context, msg string, keyVals ...any)
	Debug(msg string, keyVals ...any)
	DebugContext(ctx context.Context, msg string, keyVals ...any)
	With(keyVals ...any) Logger
	Impl() any
}
关键区别在于:
  • Info、Warn、Error 和 Debug 在记录日志时不会检查 context.Context
  • InfoContext、WarnContext、ErrorContext 和 DebugContext 会使用传入的 context 进行 trace 关联
对于默认的 zerolog 实现,*Context 方法会从 ctx 中提取当前激活的 OpenTelemetry span,并添加:
  • trace_id
  • span_id
  • 存在时添加 trace_flags
如果 context 中没有有效的 span,它们的行为就和普通日志调用一样。

Trace 关联

当你希望日志与 span 对齐时,请使用感知 context 的方法。
func (k Keeper) UpdateBalance(ctx sdk.Context, addr sdk.AccAddress, coins sdk.Coins) error {
	ctx, span := ctx.StartSpan(tracer, "UpdateBalance")
	defer span.End()

	logger := ctx.Logger().With(log.ModuleKey, "bank")
	logger.InfoContext(ctx, "updating balance", "address", addr.String())

	return nil
}
这里有两个细节很重要:
  1. sdk.Context.StartSpan(...) 会返回一个新的 sdk.Context,其中 Go 的 context.Context 已更新并包含该 span。
  2. 只有当你用这个更新后的 context 调用 logger 的某个 *Context 方法时,logger 才能看到 trace 信息。
如果不调用 *Context 方法,默认 logger 不会把 trace 字段添加到日志记录中。

log/slog

cosmossdk.io/log/v2/slog 是一个适配器,适用于已经持有标准库 *slog.Logger 的代码。
base := slog.New(handler)
logger := sdklogSlog.NewCustomLogger(base)
它本身不会增加额外的 SDK 行为。它只是让 *slog.Logger 满足 Cosmos SDK 的 Logger 接口。过滤、格式化、输出目标以及 handler 行为,都取决于底层 slog.Logger 的配置。

MultiLogger

log.NewMultiLogger(loggers...) 会返回一个 logger,把每次日志调用分发给所有被包装的 logger。 这包括:
  • 普通日志方法,例如 Info(...)
  • 感知 context 的方法,例如 InfoContext(...)
  • With(...),它会为每个被包装的 logger 派生一个子 logger
如果底层 logger 实现了 VerboseModeLogger,那么 SetVerboseMode(...) 也会被转发。 换句话说,MultiLogger 只是做分发。它不会自行合并记录,也不会自行添加新字段。

SDK 何时配置 MultiLogger

MultiLogger 并不会为每个应用自动创建。 在节点服务启动期间,SDK 会先根据 CLI/config 标志构建普通的服务 logger。这个 logger 就是常见的基于 zerolog 的 logger。 随后,SDK 会从 config/otel.yaml 初始化 OpenTelemetry。如果 telemetry.IsOtelLoggerEnabled() 报告全局 OpenTelemetry logger provider 存在活跃的日志处理器或导出器,SDK 就会像这样包装现有的服务 logger:
otelLogger := sdkSlog.NewCustomLogger(otelslog.NewLogger(""))
svrCtx.Logger = log.NewMultiLogger(svrCtx.Logger, otelLogger)
因此,当启用 OpenTelemetry 日志导出时,一次日志调用会被发送到:
  • 现有的控制台/stdout logger
  • 用于导出的、基于 OpenTelemetry 的 logger
如果没有启用 OpenTelemetry 日志,服务将继续只使用普通 logger。

otelslog 是什么

otelslog 是 Go log/slog 包的一个 OpenTelemetry bridge。 更具体地说,它提供了一个 slog.Handler 和 slog.Logger,会把 slog.Record 转换为 OpenTelemetry 日志记录,并发送给已配置的 OpenTelemetry logger provider。 在 Cosmos SDK 的启动路径中:
  • otelslog.NewLogger("") 会创建一个由该 bridge 支持的 *slog.Logger
  • cosmossdk.io/log/v2/slog.NewCustomLogger(...) 会对它进行包装,使其满足 SDK 的 Logger 接口
  • log.NewMultiLogger(...) 会把日志同时分发给普通的 zerolog logger 和 OpenTelemetry bridge
由于 slog 原生支持 InfoContext/WarnContext/ErrorContext/DebugContext 方法,otelslog 这一侧会直接接收到 context。这意味着 trace/span 关联由 OpenTelemetry 日志管线处理,SDK 不需要手动把 trace_id 字段注入到这一分支中。

两种常见配置

1. 仅 stdout

如果你没有配置 OpenTelemetry logger provider,日志只会输出到普通的 SDK logger。不过,这并不妨碍你做日志关联。 为了在 Grafana Tempo 和 Loki 这类工具中进行 trace 关联,你可以:
  1. 把 JSON 日志输出到 stdout/stderr。
  2. 用 OpenTelemetry Collector 的 filelog receiver 这类代理抓取这些日志。
  3. 把它们转发到 Loki。
  4. 在日志中按 trace_id 字段查询。
请记住,只有在使用包含活动 span 的 context 调用了某个上下文化方法时,trace_id 才会被注入到日志中。

2. 启用 OpenTelemetry 日志导出器

如果 otel.yaml 启用了带有真实日志处理器或导出器的 OpenTelemetry 日志管线,SDK 就会配置一个 MultiLogger。 在这种配置下:
  • 控制台日志仍然像以前一样工作
  • 日志也会通过 OpenTelemetry 导出
  • 感知 context 的日志调用也会把 trace 上下文带到 OpenTelemetry 分支中
当你希望 SDK 直接把日志写入 OpenTelemetry 日志后端时,应使用这条路径,这样就不需要再搭建抓取基础设施。

未来方向

当前 SDK 使用 MultiLogger,是因为默认 logger 是 zerolog,而 OpenTelemetry 目前为 slog 提供了 bridge,而不是为 zerolog 提供。 如果未来出现可用且合适的一等 zerolog bridge,那么它很可能会比维护一个单独的分发 logger 更简单。相关讨论:
cosmossdk.io/log/v2 is the Cosmos SDK logging package. At a high level, there are three pieces to understand:
  1. log.NewLogger(...) creates the default Cosmos SDK logger. It is backed by zerolog.
  2. cosmossdk.io/log/v2/slog lets you satisfy the same SDK Logger interface with a standard library *slog.Logger.
  3. log.NewMultiLogger(...) fans one log call out to multiple SDK loggers. The SDK uses this during server startup when OpenTelemetry log exporting is enabled. To learn more about how we support OpenTelemetry, read the Telemetry docs.
If you only need ordinary SDK logging, you usually only need log.NewLogger, which is automatically provisioned and set on sdk.Context.

Default Logger

The default implementation is a small wrapper around zerolog.
logger := log.NewLogger(os.Stderr)

logger.Info("starting app", "chain_id", chainID)
logger.Error("failed to load state", "err", err)
NewLogger writes human-readable console output by default. The server command wiring switches options based on CLI configuration, for example:
  • OutputJSONOption() for JSON logs
  • LevelOption(...) for a global log level
  • FilterOption(...) for module-based filtering
  • TraceOption(true) to include stack traces on error logs
  • VerboseLevelOption(...) for temporary verbose mode
The SDK also uses the module field consistently. The package exposes log.ModuleKey for this:
logger = logger.With(log.ModuleKey, "bank")
logger.Info("send coins", "from", from, "to", to)
That matters because the log filter implementation keys off the module field when parsing values such as consensus:debug,*:error.

Structured Context

Logger.With(...) returns a derived logger with additional fields:
keeperLogger := logger.With(log.ModuleKey, "staking", "component", "keeper")
keeperLogger.Info("validator updated", "operator", valAddr)
This is the normal way to attach stable metadata to a logger instance.

Context-Aware Logging

The v2 Logger interface adds *Context methods:
type Logger interface {
	Info(msg string, keyVals ...any)
	InfoContext(ctx context.Context, msg string, keyVals ...any)
	Warn(msg string, keyVals ...any)
	WarnContext(ctx context.Context, msg string, keyVals ...any)
	Error(msg string, keyVals ...any)
	ErrorContext(ctx context.Context, msg string, keyVals ...any)
	Debug(msg string, keyVals ...any)
	DebugContext(ctx context.Context, msg string, keyVals ...any)
	With(keyVals ...any) Logger
	Impl() any
}
The important distinction is:
  • Info, Warn, Error, and Debug log without inspecting a context.Context
  • InfoContext, WarnContext, ErrorContext, and DebugContext use the provided context for trace correlation
For the default zerolog implementation, the *Context methods extract the active OpenTelemetry span from ctx and add:
  • trace_id
  • span_id
  • trace_flags when present
If there is no valid span in the context, they behave like normal log calls.

Trace Correlation

When you want logs to line up with spans, use the context-aware methods.
func (k Keeper) UpdateBalance(ctx sdk.Context, addr sdk.AccAddress, coins sdk.Coins) error {
	ctx, span := ctx.StartSpan(tracer, "UpdateBalance")
	defer span.End()

	logger := ctx.Logger().With(log.ModuleKey, "bank")
	logger.InfoContext(ctx, "updating balance", "address", addr.String())

	return nil
}
Two details matter here:
  1. sdk.Context.StartSpan(...) returns a new sdk.Context with the Go context.Context updated to include the span.
  2. The logger only sees trace information when you call one of the logger’s *Context methods with that updated context.
Without the *Context call, the default logger will not add trace fields to the log record.

log/slog

cosmossdk.io/log/v2/slog is an adapter for code that already has a standard library *slog.Logger.
base := slog.New(handler)
logger := sdklogSlog.NewCustomLogger(base)
This does not add extra SDK behavior by itself. It simply makes a *slog.Logger satisfy the Cosmos SDK Logger interface. Filtering, formatting, sinks, and handler behavior are whatever the underlying slog.Logger is configured to do.

MultiLogger

log.NewMultiLogger(loggers...) returns a logger that dispatches each log call to every wrapped logger. That includes:
  • ordinary log methods such as Info(...)
  • context-aware methods such as InfoContext(...)
  • With(...), which derives a child logger for each wrapped logger
If an underlying logger implements VerboseModeLogger, SetVerboseMode(...) is also forwarded. In other words, MultiLogger is just fanout. It does not merge records or add new fields on its own.

When The SDK Configures MultiLogger

MultiLogger is not created for every app automatically. During the node’s server start, the SDK first builds the normal server logger from CLI/config flags. That logger is the usual zerolog-backed logger. Then the SDK initializes OpenTelemetry from config/otel.yaml. If telemetry.IsOtelLoggerEnabled() reports that the global OpenTelemetry logger provider has active log processors/exporters, the SDK wraps the existing server logger like this:
otelLogger := sdkSlog.NewCustomLogger(otelslog.NewLogger(""))
svrCtx.Logger = log.NewMultiLogger(svrCtx.Logger, otelLogger)
So when OpenTelemetry log exporting is enabled, one log call is sent to:
  • the existing console/stdout logger
  • an OpenTelemetry-backed logger for export
If OpenTelemetry logging is not enabled, the server continues using only the normal logger.

What otelslog Is

otelslog is an OpenTelemetry bridge for Go’s log/slog package. More specifically, it provides a slog.Handler and slog.Logger that convert slog.Record values into OpenTelemetry log records and sends them to the configured OpenTelemetry logger provider. In the Cosmos SDK startup path:
  • otelslog.NewLogger("") creates an *slog.Logger backed by that bridge
  • cosmossdk.io/log/v2/slog.NewCustomLogger(...) wraps it so it satisfies the SDK Logger interface
  • log.NewMultiLogger(...) fans logs out to both the normal zerolog logger and the OpenTelemetry bridge
Because slog has native InfoContext/WarnContext/ErrorContext/DebugContext methods, the otelslog side receives the context directly. That means trace/span correlation is handled by the OpenTelemetry logging pipeline without the SDK needing to manually inject trace_id fields into that branch.

Two Common Setups

1. Stdout only

If you do not configure an OpenTelemetry logger provider, logs only go to the normal SDK logger output. This does not restrict you from log correlation, however. For trace correlation in tools such as Grafana Tempo and Loki, you can:
  1. Emit JSON logs to stdout/stderr.
  2. Scrape those logs with an agent such as the OpenTelemetry Collector filelog receiver.
  3. Forward them to Loki.
  4. Query by the trace_id field in the logs.
Remember, trace_id is only injected into the log if a contextual method was called with a context that contains an active span.

2. OpenTelemetry log exporter enabled

If otel.yaml enables an OpenTelemetry log pipeline with real log processors/exporters, the SDK configures a MultiLogger. In that setup:
  • console logging still works as before
  • logs are also exported through OpenTelemetry
  • context-aware log calls carry trace context into the OpenTelemetry branch as well
This is the path to use when you want the SDK to write logs directly into an OpenTelemetry logging backend, which eliminates the need to set up scraping infrastructure.

Future Direction

Today the SDK uses a MultiLogger because the default logger is zerolog, while OpenTelemetry currently offers a bridge for slog rather than zerolog. If a first-class zerolog bridge becomes available and suitable, that would likely be a simpler export path than maintaining a separate fanout logger. Relevant discussion: