-
Notifications
You must be signed in to change notification settings - Fork 651
feat(core): add controller log→OTLP bridge (gated OTEL_LOGGING_ENABLED) #2151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
b91dd16
633d83d
e1e172a
04762af
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| package telemetry | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
|
|
||
| otelzap "go.opentelemetry.io/contrib/bridges/otelzap" | ||
| "go.opentelemetry.io/contrib/exporters/autoexport" | ||
| logglobal "go.opentelemetry.io/otel/log/global" | ||
| sdklog "go.opentelemetry.io/otel/sdk/log" | ||
| "go.uber.org/zap" | ||
| "go.uber.org/zap/zapcore" | ||
| crzap "sigs.k8s.io/controller-runtime/pkg/log/zap" | ||
|
|
||
| "github.com/kagent-dev/kagent/go/core/pkg/env" | ||
| ) | ||
|
|
||
| const loggerBridgeName = "github.com/kagent-dev/kagent/go/core" | ||
|
|
||
| // InitLoggerProvider configures an OTLP LoggerProvider and registers it as the | ||
| // global OTel logger provider. The exporter type and endpoint are read from the | ||
| // standard OTEL environment variables via autoexport, mirroring | ||
| // InitTracerProvider. The returned shutdown function must be called on process | ||
| // exit to flush in-flight log records. When OTEL_LOGGING_ENABLED is unset the | ||
| // pipeline is not created and a no-op shutdown is returned (default-OFF). | ||
| func InitLoggerProvider(ctx context.Context, serviceVersion string) (func(context.Context) error, error) { | ||
| if !env.OtelLoggingEnabled.Get() { | ||
| return func(context.Context) error { return nil }, nil | ||
| } | ||
|
|
||
| exporter, err := autoexport.NewLogExporter(ctx) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("create log exporter: %w", err) | ||
| } | ||
|
|
||
| res, err := newTelemetryResource(ctx, serviceVersion) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| lp := sdklog.NewLoggerProvider( | ||
| sdklog.WithProcessor(sdklog.NewBatchProcessor(exporter)), | ||
| sdklog.WithResource(res), | ||
| ) | ||
|
|
||
| logglobal.SetLoggerProvider(lp) | ||
|
|
||
| return lp.Shutdown, nil | ||
| } | ||
|
|
||
| // ControllerZapOpts returns controller-runtime zap options. When | ||
| // OTEL_LOGGING_ENABLED is set it additively tees the controller's stdout zap | ||
| // core with an otelzap bridge core, routing the controller's own logs through | ||
| // the global OTLP LoggerProvider while preserving stdout logging. When disabled | ||
| // it returns no options, leaving the logger byte-identical to upstream. | ||
| // | ||
| // InitLoggerProvider must be called first so the bridge core binds to the | ||
| // configured global LoggerProvider. | ||
| func ControllerZapOpts() []crzap.Opts { | ||
| if !env.OtelLoggingEnabled.Get() { | ||
| return nil | ||
| } | ||
| bridgeCore := otelzap.NewCore(loggerBridgeName, | ||
| otelzap.WithLoggerProvider(logglobal.GetLoggerProvider()), | ||
| ) | ||
| return []crzap.Opts{ | ||
| crzap.RawZapOpts(zap.WrapCore(func(core zapcore.Core) zapcore.Core { | ||
| return zapcore.NewTee(core, bridgeCore) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
| })), | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| package telemetry | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| crzap "sigs.k8s.io/controller-runtime/pkg/log/zap" | ||
| ) | ||
|
|
||
| // TestControllerZapOpts_DisabledByDefault verifies no zap options are added when | ||
| // OTEL_LOGGING_ENABLED is off, keeping the controller logger unchanged. | ||
| func TestControllerZapOpts_DisabledByDefault(t *testing.T) { | ||
| t.Setenv("OTEL_LOGGING_ENABLED", "false") | ||
| if opts := ControllerZapOpts(); opts != nil { | ||
| t.Fatalf("expected nil opts when logging disabled, got %d", len(opts)) | ||
| } | ||
| } | ||
|
|
||
| // 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's also test
|
||
| t.Setenv("OTEL_LOGGING_ENABLED", "true") | ||
|
|
||
| opts := ControllerZapOpts() | ||
| if len(opts) != 1 { | ||
| t.Fatalf("expected 1 zap opt when logging enabled, got %d", len(opts)) | ||
| } | ||
|
|
||
| // Building and using the logger must not panic even though no real OTLP | ||
| // LoggerProvider is configured (the global provider defaults to a no-op). | ||
| logger := crzap.New(append([]crzap.Opts{crzap.UseDevMode(true)}, opts...)...) | ||
| logger.Info("tee smoke test") | ||
| } | ||
|
|
||
| // TestInitLoggerProvider_DisabledNoop verifies the returned shutdown is a safe | ||
| // no-op when logging is disabled. | ||
| func TestInitLoggerProvider_DisabledNoop(t *testing.T) { | ||
| t.Setenv("OTEL_LOGGING_ENABLED", "false") | ||
|
|
||
| shutdown, err := InitLoggerProvider(context.Background(), "test") | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if shutdown == nil { | ||
| t.Fatal("expected non-nil shutdown") | ||
| } | ||
| if err := shutdown(context.Background()); err != nil { | ||
| t.Fatalf("no-op shutdown returned error: %v", err) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,29 @@ func InitTracerProvider(ctx context.Context, serviceVersion string) (func(contex | |
| return nil, fmt.Errorf("create span exporter: %w", err) | ||
| } | ||
|
|
||
| res, err := newTelemetryResource(ctx, serviceVersion) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| tp := sdktrace.NewTracerProvider( | ||
| sdktrace.WithBatcher(exporter), | ||
| sdktrace.WithResource(res), | ||
| ) | ||
|
|
||
| otel.SetTracerProvider(tp) | ||
| otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( | ||
| propagation.TraceContext{}, | ||
| propagation.Baggage{}, | ||
| )) | ||
|
|
||
| return tp.Shutdown, nil | ||
| } | ||
|
|
||
| // newTelemetryResource builds the OTel resource shared by the controller's | ||
| // signal pipelines (traces and logs), keeping their resource attributes | ||
| // identical. | ||
| func newTelemetryResource(ctx context.Context, serviceVersion string) (*resource.Resource, error) { | ||
| instanceID, err := os.Hostname() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since Let's build the resource once in |
||
| if err != nil || instanceID == "" { | ||
| instanceID = uuid.New().String() | ||
|
|
@@ -67,17 +90,5 @@ func InitTracerProvider(ctx context.Context, serviceVersion string) (func(contex | |
| if err != nil { | ||
| return nil, fmt.Errorf("create OTEL resource: %w", err) | ||
| } | ||
|
|
||
| tp := sdktrace.NewTracerProvider( | ||
| sdktrace.WithBatcher(exporter), | ||
| sdktrace.WithResource(res), | ||
| ) | ||
|
|
||
| otel.SetTracerProvider(tp) | ||
| otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( | ||
| propagation.TraceContext{}, | ||
| propagation.Baggage{}, | ||
| )) | ||
|
|
||
| return tp.Shutdown, nil | ||
| return res, nil | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this actually gets us log-trace correlation as-is, which is what you're trying to deliver?
otelzap.Coreonly adds trace_id/span_id to a record when a context.Context is passed as a zap field. controller-runtime logs through logr -> zapr, andlog.FromContext(ctx)only threads values in viaWithValues, so I am not sure these records contain spans linked to them.