feat(core): add controller log→OTLP bridge (gated OTEL_LOGGING_ENABLED)#2151
feat(core): add controller log→OTLP bridge (gated OTEL_LOGGING_ENABLED)#2151braghettos wants to merge 1 commit into
Conversation
…BLED)
Additively tee the controller's zap logger with an otelzap bridge core so
the controller's own logs can be exported over OTLP alongside the traces
already emitted via InitTracerProvider, without changing stdout logging.
- New internal/telemetry/logging.go:
- InitLoggerProvider builds an OTLP LoggerProvider via autoexport
(same env-driven exporter resolution as InitTracerProvider) and sets
it as the global OTel logger provider.
- ControllerZapOpts returns a RawZapOpts(zap.WrapCore(NewTee(...)))
option that tees the stdout core with an otelzap bridge core bound to
that provider.
- tracing.go: extract the shared newTelemetryResource helper so traces and
logs carry identical resource attributes.
- app.go: initialize the logger provider before building the controller
logger (so the bridge binds to it) and add the tee via ControllerZapOpts.
Gated behind OTEL_LOGGING_ENABLED; when unset both functions are no-ops
and the logger is byte-identical to today. Adds otelzap v0.19.0 (matching
otel/log v0.20.0); no other dependency changes.
Refs: kagent-dev#2148
Signed-off-by: Diego Braga <diego.braga86@gmail.com>
There was a problem hiding this comment.
Pull request overview
Adds an optional, default-off OpenTelemetry logging pipeline for the controller, enabling controller-runtime zap logs to be exported via OTLP (in addition to existing trace export) for log/trace correlation.
Changes:
- Introduces
InitLoggerProviderto configure and register a global OTelLoggerProvidervia env-driven OTLP autoexport (gated byOTEL_LOGGING_ENABLED). - Adds
ControllerZapOptsto tee the controller’s zap core to anotelzapbridge core while preserving stdout logging. - Refactors trace setup to share a common OTel
Resourcebuilder so traces and logs carry identical resource attributes.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| go/go.mod | Adds otelzap bridge dependency and promotes otel/log to a direct dependency. |
| go/go.sum | Updates module checksums for the new/now-direct dependencies. |
| go/core/pkg/app/app.go | Initializes OTel log provider before constructing the controller logger and applies the zap tee option. |
| go/core/internal/telemetry/tracing.go | Extracts shared newTelemetryResource helper for consistent resource attributes. |
| go/core/internal/telemetry/logging.go | New OTLP logger provider init + controller zap tee option (gated). |
| go/core/internal/telemetry/logging_test.go | Adds unit tests for gating behavior and no-op shutdown when disabled. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| defer func() { | ||
| shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||
| defer cancel() | ||
| if err := shutdownLogging(shutdownCtx); err != nil { | ||
| setupLog.Error(err, "failed to shutdown logging") |
krisztianfekete
left a comment
There was a problem hiding this comment.
Thanks for the PR!
Added some comments, and just like in #2149, we'd like to see docs/examples to set it up, and some screenshots showing that the changes have been tested in a real setup. An OTel collector + debugexporter is perfect for this purpose if you don't want to ingest the logs into a proper logging backend.
| if !env.OtelLoggingEnabled.Get() { | ||
| return nil | ||
| } | ||
| bridgeCore := otelzap.NewCore(loggerBridgeName, |
There was a problem hiding this comment.
I don't think this actually gets us log-trace correlation as-is, which is what you're trying to deliver?
otelzap.Core only adds trace_id/span_id to a record when a context.Context is passed as a zap field. controller-runtime logs through logr -> zapr, and log.FromContext(ctx) only threads values in via WithValues, so I am not sure these records contain spans linked to them.
| ) | ||
| return []crzap.Opts{ | ||
| crzap.RawZapOpts(zap.WrapCore(func(core zapcore.Core) zapcore.Core { | ||
| return zapcore.NewTee(core, bridgeCore) |
There was a problem hiding this comment.
The OTel log SDK puts severity filtering in a processor, not in the exporter or the bridge, so someone running at error level only expects error+ logs, but when they enable this flag it will ship all their info/debug logs to the backend.
We can fix it via https://pkg.go.dev/go.opentelemetry.io/contrib/processors/minsev
| // signal pipelines (traces and logs), keeping their resource attributes | ||
| // identical. | ||
| func newTelemetryResource(ctx context.Context, serviceVersion string) (*resource.Resource, error) { | ||
| instanceID, err := os.Hostname() |
There was a problem hiding this comment.
Since newTelemetryResource is now called once by InitTracerProvider and once by InitLoggerProvider, and os.Hostname() falls back to a fresh uuid.New() on failure, it's possible that processes can end up with two different service.instance.ids across the two ttelemetry signals.
Let's build the resource once in app.go and pass the same *resource.Resource into both initializers instead.
| // TestControllerZapOpts_EnabledTeesLogger verifies that when OTEL_LOGGING_ENABLED | ||
| // is set, an otelzap bridge option is returned and the resulting logger is usable | ||
| // (the bridge tees onto the stdout core without panicking). | ||
| func TestControllerZapOpts_EnabledTeesLogger(t *testing.T) { |
There was a problem hiding this comment.
Let's also test
- a record actually having trace_id when there's an active span, and
- severity gating vs the stdout level
What
Adds an optional controller log → OTLP bridge: the controller's own zap logs can be exported over OTLP alongside the traces already emitted by
InitTracerProvider, enabling log/trace correlation in the controller. This is the third and final observability gap proposed in #2148.Today
go/core/internal/telemetry/only sets up traces; the controller's zap logs are stdout-only, so they can't be shipped to an OTLP backend or correlated with spans.How
internal/telemetry/logging.go:InitLoggerProviderbuilds an OTLPLoggerProviderviaautoexport.NewLogExporter(same env-driven exporter/endpoint resolution asInitTracerProvider) and registers it as the global OTel logger provider.ControllerZapOptsreturns aRawZapOpts(zap.WrapCore(zapcore.NewTee(core, bridgeCore)))option that additively tees the stdout zap core with anotelzapbridge core bound to that provider — stdout logging is preserved, OTLP is added.tracing.go: extracted the sharednewTelemetryResourcehelper so traces and logs carry identical resource attributes (no behaviour change toInitTracerProvider).pkg/app/app.go: initialize the logger provider before building the controller logger (so the bridge binds to the configured global provider), then add the tee viaControllerZapOpts.Gating (default-OFF, additive)
Enabled only when
OTEL_LOGGING_ENABLED=true(the flag already registered inpkg/env). When unset, bothInitLoggerProviderandControllerZapOptsare no-ops and the controller logger is byte-identical to today.Testing
logging_test.go— assertsControllerZapOpts()returns no options when disabled, returns a working teed logger when enabled (no panic with the default no-op global provider), and thatInitLoggerProvider's shutdown is a safe no-op when disabled.go build ./core/...,go test -race ./core/internal/telemetry/..., andgolangci-lint runall pass.Notes
go.opentelemetry.io/contrib/bridges/otelzap v0.19.0(matches the existingotel/log v0.20.0) and promotesotel/logfrom indirect to direct; no other dependency or version changes.Refs: #2148