Repository: kubernetes-sigs/controller-runtime (2026-04-05T09:52:43Z)

Issue:
Improve logging when using PartialObjectMetadata
This week, we spent some time debugging a complex issue in cert-manager approver-policy, and we would have been a lot more effective if controller-runtime had logged information about the actual resource (probably kind) behind the `PartialObjectMetadata` when the controller only cares for metadata of resources. Below is a redacted extract of the logs from a real cluster having this issue. We would love to know which resource cache was not synced in time, since approver-policy is watching for metadata on multiple resources. 😅

````json
{"time":"2026-04-01T10:24:37.979652102Z","level":"INFO","msg":"Starting EventSource","controller":"certificaterequest","controllerGroup":"cert-manager.io","controllerKind":"CertificateRequest","source":"kind source: *v1.PartialObjectMetadata","logger":"controller-manager"}
{"time":"2026-04-01T10:24:37.97968379Z","level":"INFO","msg":"Starting EventSource","controller":"certificaterequest","controllerGroup":"cert-manager.io","controllerKind":"CertificateRequest","source":"kind source: *v1.PartialObjectMetadata","logger":"controller-manager"}
{"time":"2026-04-01T10:24:37.979661641Z","level":"INFO","msg":"Starting EventSource","controller":"certificaterequest","controllerGroup":"cert-manager.io","controllerKind":"CertificateRequest","source":"kind source: *v1.PartialObjectMetadata","logger":"controller-manager"}
{"time":"2026-04-01T10:24:37.979674009Z","level":"INFO","msg":"Starting EventSource","controller":"certificaterequest","controllerGroup":"cert-manager.io","controllerKind":"CertificateRequest","source":"kind source: *v1.PartialObjectMetadata","logger":"controller-manager"}
{"time":"2026-04-01T10:24:37.979679689Z","level":"INFO","msg":"Starting EventSource","controller":"certificaterequest","controllerGroup":"cert-manager.io","controllerKind":"CertificateRequest","source":"kind source: *v1.CertificateRequest","logger":"controller-manager"}
{"time":"2026-04-01T10:24:37.979693096Z","level":"INFO","msg":"Starting EventSource","controller":"certificaterequest","controllerGroup":"cert-manager.io","controllerKind":"CertificateRequest","source":"kind source: *v1.PartialObjectMetadata","logger":"controller-manager"}
{"time":"2026-04-01T10:24:37.979889744Z","level":"DEBUG+1","msg":"Starting reflector","logger":"controller-manager/controller-runtime/cache","type":"*v1.PartialObjectMetadata","resyncPeriod":37016549581214,"reflector":"k8s.io/client-go@v0.35.2/tools/cache/reflector.go:289"}
{"time":"2026-04-01T10:24:37.979911295Z","level":"DEBUG+1","msg":"Listing and watching","logger":"controller-manager/controller-runtime/cache","type":"*v1.PartialObjectMetadata","reflector":"k8s.io/client-go@v0.35.2/tools/cache/reflector.go:289"}
{"time":"2026-04-01T10:24:37.980355279Z","level":"DEBUG+1","msg":"Starting reflector","logger":"controller-manager/controller-runtime/cache","type":"*v1.PartialObjectMetadata","resyncPeriod":34589431939704,"reflector":"k8s.io/client-go@v0.35.2/tools/cache/reflector.go:289"}
{"time":"2026-04-01T10:24:37.980474094Z","level":"DEBUG+1","msg":"Starting reflector","logger":"controller-manager/controller-runtime/cache","type":"*v1.PartialObjectMetadata","resyncPeriod":37769082400875,"reflector":"k8s.io/client-go@v0.35.2/tools/cache/reflector.go:289"}
{"time":"2026-04-01T10:24:37.980488544Z","level":"DEBUG+1","msg":"Listing and watching","logger":"controller-manager/controller-runtime/cache","type":"*v1.PartialObjectMetadata","reflector":"k8s.io/client-go@v0.35.2/tools/cache/reflector.go:289"}
{"time":"2026-04-01T10:24:37.980650845Z","level":"DEBUG+1","msg":"Starting reflector","logger":"controller-manager/controller-runtime/cache","type":"*v1.PartialObjectMetadata","resyncPeriod":36665984018484,"reflector":"k8s.io/client-go@v0.35.2/tools/cache/reflector.go:289"}
{"time":"2026-04-01T10:24:37.980664479Z","level":"DEBUG+1","msg":"Listing and watching","logger":"controller-manager/controller-runtime/cache","type":"*v1.PartialObjectMetadata","reflector":"k8s.io/client-go@v0.35.2/tools/cache/reflector.go:289"}
{"time":"2026-04-01T10:24:37.980831847Z","level":"DEBUG+1","msg":"Starting reflector","logger":"controller-manager/controller-runtime/cache","type":"*v1.PartialObjectMetadata","resyncPeriod":37695204345075,"reflector":"k8s.io/client-go@v0.35.2/tools/cache/reflector.go:289"}
{"time":"2026-04-01T10:24:37.980844748Z","level":"DEBUG+1","msg":"Listing and watching","logger":"controller-manager/controller-runtime/cache","type":"*v1.PartialObjectMetadata","reflector":"k8s.io/client-go@v0.35.2/tools/cache/reflector.go:289"}
{"time":"2026-04-01T10:24:37.981012847Z","level":"DEBUG+1","msg":"Listing and watching","logger":"controller-manager/controller-runtime/cache","type":"*v1.PartialObjectMetadata","reflector":"k8s.io/client-go@v0.35.2/tools/cache/reflector.go:289"}
{"time":"2026-04-01T10:24:37.986083642Z","level":"DEBUG","msg":"Data couldn't be fetched in watchlist mode. Falling back to regular list. This is expected if watchlist is not supported or disabled in kube-apiserver.","logger":"controller-manager/controller-runtime/cache","err":"ListOptions.meta.k8s.io \"\" is invalid: sendInitialEvents: Forbidden: sendInitialEvents is forbidden for watch unless the WatchList feature gate is enabled"}
{"time":"2026-04-01T10:24:37.986284515Z","level":"DEBUG","msg":"Data couldn't be fetched in watchlist mode. Falling back to regular list. This is expected if watchlist is not supported or disabled in kube-apiserver.","logger":"controller-manager/controller-runtime/cache","err":"ListOptions.meta.k8s.io \"\" is invalid: sendInitialEvents: Forbidden: sendInitialEvents is forbidden for watch unless the WatchList feature gate is enabled"}
{"time":"2026-04-01T10:24:37.986453447Z","level":"DEBUG","msg":"Data couldn't be fetched in watchlist mode. Falling back to regular list. This is expected if watchlist is not supported or disabled in kube-apiserver.","logger":"controller-manager/controller-runtime/cache","err":"ListOptions.meta.k8s.io \"\" is invalid: sendInitialEvents: Forbidden: sendInitialEvents is forbidden for watch unless the WatchList feature gate is enabled"}
{"time":"2026-04-01T10:24:37.986554022Z","level":"DEBUG","msg":"Data couldn't be fetched in watchlist mode. Falling back to regular list. This is expected if watchlist is not supported or disabled in kube-apiserver.","logger":"controller-manager/controller-runtime/cache","err":"ListOptions.meta.k8s.io \"\" is invalid: sendInitialEvents: Forbidden: sendInitialEvents is forbidden for watch unless the WatchList feature gate is enabled"}
{"time":"2026-04-01T10:24:37.986679063Z","level":"DEBUG","msg":"Data couldn't be fetched in watchlist mode. Falling back to regular list. This is expected if watchlist is not supported or disabled in kube-apiserver.","logger":"controller-manager/controller-runtime/cache","err":"ListOptions.meta.k8s.io \"\" is invalid: sendInitialEvents: Forbidden: sendInitialEvents is forbidden for watch unless the WatchList feature gate is enabled"}
{"time":"2026-04-01T10:24:37.98680437Z","level":"DEBUG","msg":"Data couldn't be fetched in watchlist mode. Falling back to regular list. This is expected if watchlist is not supported or disabled in kube-apiserver.","logger":"controller-manager/controller-runtime/cache","err":"ListOptions.meta.k8s.io \"\" is invalid: sendInitialEvents: Forbidden: sendInitialEvents is forbidden for watch unless the WatchList feature gate is enabled"}
{"time":"2026-04-01T10:24:37.994097946Z","level":"DEBUG+2","msg":"Caches populated","logger":"controller-manager/controller-runtime/cache","type":"*v1.PartialObjectMetadata","reflector":"k8s.io/client-go@v0.35.2/tools/cache/reflector.go:289"}
{"time":"2026-04-01T10:24:37.999583103Z","level":"DEBUG+2","msg":"Caches populated","logger":"controller-manager/controller-runtime/cache","type":"*v1.PartialObjectMetadata","reflector":"k8s.io/client-go@v0.35.2/tools/cache/reflector.go:289"}
{"time":"2026-04-01T10:24:38.007432982Z","level":"DEBUG+2","msg":"Caches populated","logger":"controller-manager/controller-runtime/cache","type":"*v1.PartialObjectMetadata","reflector":"k8s.io/client-go@v0.35.2/tools/cache/reflector.go:289"}
{"time":"2026-04-01T10:24:38.023072539Z","level":"DEBUG+2","msg":"Caches populated","logger":"controller-manager/controller-runtime/cache","type":"*v1.PartialObjectMetadata","reflector":"k8s.io/client-go@v0.35.2/tools/cache/reflector.go:289"}
{"time":"2026-04-01T10:24:38.06987908Z","level":"DEBUG+2","msg":"Caches populated","logger":"controller-manager/controller-runtime/cache","type":"*v1.PartialObjectMetadata","reflector":"k8s.io/client-go@v0.35.2/tools/cache/reflector.go:289"}
{"time":"2026-04-01T10:24:47.979613356Z","level":"DEBUG-1","msg":"workqueue_items","controller":"certificaterequest","logger":"controller-manager","items":[]}
{"time":"2026-04-01T10:26:37.979887312Z","level":"ERROR","msg":"Could not wait for Cache to sync","controller":"certificaterequest","controllerGroup":"cert-manager.io","controllerKind":"CertificateRequest","source":"kind source: *v1.PartialObjectMetadata","logger":"controller-manager","err":"failed to wait for certificaterequest caches to sync kind source: *v1.PartialObjectMetadata: timed out waiting for cache to be synced for Kind *v1.PartialObjectMetadata"}
```` 


Discussion:
Makes sense and should do the same for unstructured. Should only require specialcasing the two here: https://github.com/kubernetes-sigs/controller-runtime/blob/885e77d7d9fc1a010362c5fae19cda13e64f3ae8/pkg/internal/source/kind.go#L120
Thanks for the suggestion, Alvaro! I just opened up https://github.com/kubernetes-sigs/controller-runtime/pull/3494, PTAL!



Patch:
diff --git a/.gitignore b/.gitignore
index 2ddc5a8b87..31d2570309 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@
 
 # editor and IDE paraphernalia
 .idea
+*.iml
 *.swp
 *.swo
 *~
diff --git a/pkg/internal/source/kind.go b/pkg/internal/source/kind.go
index a28aeb177e..4eb82f9825 100644
--- a/pkg/internal/source/kind.go
+++ b/pkg/internal/source/kind.go
@@ -8,6 +8,8 @@ import (
 	"time"
 
 	"k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/util/wait"
 	toolscache "k8s.io/client-go/tools/cache"
@@ -116,10 +118,18 @@ func (ks *Kind[object, request]) Start(ctx context.Context, queue workqueue.Type
 }
 
 func (ks *Kind[object, request]) String() string {
-	if !isNil(ks.Type) {
+	if isNil(ks.Type) {
+		return "kind source: unknown type"
+	}
+
+	switch v := any(ks.Type).(type) {
+	case *unstructured.Unstructured, *metav1.PartialObjectMetadata:
+		gvk := v.(client.Object).GetObjectKind().GroupVersionKind()
+		gv, kind := gvk.ToAPIVersionAndKind()
+		return fmt.Sprintf("kind source: %T[%s %s]", v, gv, kind)
+	default:
 		return fmt.Sprintf("kind source: %T", ks.Type)
 	}
-	return "kind source: unknown type"
 }
 
 // WaitForSync implements SyncingSource to allow controllers to wait with starting
@@ -133,7 +143,7 @@ func (ks *Kind[object, request]) WaitForSync(ctx context.Context) error {
 		if errors.Is(ctx.Err(), context.Canceled) {
 			return nil
 		}
-		return fmt.Errorf("timed out waiting for cache to be synced for Kind %T", ks.Type)
+		return fmt.Errorf("timed out waiting for cache to be synced for %s", ks.String())
 	}
 }
 


Test patch:
diff --git a/pkg/internal/source/internal_test.go b/pkg/internal/source/internal_test.go
index 73eb1a1d28..bebdc63fef 100644
--- a/pkg/internal/source/internal_test.go
+++ b/pkg/internal/source/internal_test.go
@@ -21,6 +21,7 @@ import (
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/client-go/tools/cache"
 	"k8s.io/client-go/util/workqueue"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -293,6 +294,28 @@ var _ = Describe("Internal", func() {
 			}
 			Expect(kind.String()).Should(Equal("kind source: *v1.Pod"))
 		})
+		It("should return kind source underlying type for Unstructured", func() {
+			kind := internal.Kind[*unstructured.Unstructured, reconcile.Request]{
+				Type: &unstructured.Unstructured{
+					Object: map[string]any{
+						"apiVersion": "apps/v1",
+						"kind":       "Deployment",
+					},
+				},
+			}
+			Expect(kind.String()).Should(Equal("kind source: *unstructured.Unstructured[apps/v1 Deployment]"))
+		})
+		It("should return kind source underlying type for PartialObjectMetadata", func() {
+			kind := internal.Kind[*metav1.PartialObjectMetadata, reconcile.Request]{
+				Type: &metav1.PartialObjectMetadata{
+					TypeMeta: metav1.TypeMeta{
+						APIVersion: "apps/v1",
+						Kind:       "Deployment",
+					},
+				},
+			}
+			Expect(kind.String()).Should(Equal("kind source: *v1.PartialObjectMetadata[apps/v1 Deployment]"))
+		})
 	})
 })