Skip to content

obs(spireentry): add Prometheus metrics and OTel tracing to entry reconciler [experimental] - #712

Draft
antoinedao-cohere wants to merge 6 commits into
spiffe:mainfrom
antoinedao-cohere:controller-observability-improvements
Draft

obs(spireentry): add Prometheus metrics and OTel tracing to entry reconciler [experimental]#712
antoinedao-cohere wants to merge 6 commits into
spiffe:mainfrom
antoinedao-cohere:controller-observability-improvements

Conversation

@antoinedao-cohere

Copy link
Copy Markdown

Experimental / RFC — this is a draft I'm sharing with colleagues and maintainers for early feedback. It is not intended as a merge-ready PR today, but I think the approach could be genuinely useful and I'd love to hear whether maintainers see value in it.

Motivation

The default controller-runtime / kubebuilder metric tooling only instruments the Kubernetes reconcile loops (queue depth, processing latency, errors). It tells you nothing about what is happening inside a reconcile pass — specifically the SPIRE entry reconciler, which has its own internal loop that is not a standard controller-runtime reconciler.

We are seeing pod attestation delays of up to ~15 minutes in production. A pod is admitted to the cluster but its SPIRE registration entry is not created for a very long time, blocking workload identity issuance. We suspect the controller is the source of the delay but we have no visibility into which phase is slow. This change adds the instrumentation we need to confirm or rule that out.

What this adds

Prometheus metrics (pkg/metrics)

Metric Type Labels What it tells you
spire_controller_manager_reconcile_duration_seconds Histogram kind, trigger, result End-to-end wall-clock time per reconcile pass
spire_controller_manager_reconcile_phase_duration_seconds Histogram phase Per-phase breakdown: list_entries, list_static_entries, list_cluster_spiffeids, build_node_map, render_state, diff, delete, create, update, status_update
spire_controller_manager_spire_api_request_duration_seconds Histogram operation Latency of each SPIRE server RPC (list_entries, batch_create, batch_update, batch_delete)
spire_controller_manager_spire_entry_write_total Counter operation, code Per-RPC result counts (ok, already_exists, not_found, error)
spire_controller_manager_reconcile_trigger_total Counter result Triggers enqueued vs dropped — a high drop rate means new pod events are being coalesced away
spire_controller_manager_entry_render_cache_total Counter result Render cache hit vs miss ratio
spire_controller_manager_pod_attestation_delay_seconds Histogram Primary user-facing SLI: time from pod.CreationTimestamp to successful SPIRE entry creation
spire_controller_manager_reconcile_batch_entries Gauge operation Batch size at the point each write RPC is issued

Key PromQL queries

# P99 end-to-end reconcile pass duration
histogram_quantile(0.99,
  rate(spire_controller_manager_reconcile_duration_seconds_bucket[5m]))

# Which phase is the bottleneck?
histogram_quantile(0.99,
  rate(spire_controller_manager_reconcile_phase_duration_seconds_bucket[5m]))
  by (phase)

# SPIRE server API latency by operation
histogram_quantile(0.99,
  rate(spire_controller_manager_spire_api_request_duration_seconds_bucket[5m]))
  by (operation)

# Pod attestation SLI — P95 delay
histogram_quantile(0.95,
  rate(spire_controller_manager_pod_attestation_delay_seconds_bucket[15m]))

# Fraction of triggers that are dropped (high = back-pressure)
sum(rate(spire_controller_manager_reconcile_trigger_total{result="dropped"}[5m]))
  /
sum(rate(spire_controller_manager_reconcile_trigger_total[5m]))

Distributed tracing (pkg/tracing)

When spec.tracing.enabled: true is set in the controller config, an OTLP/gRPC trace exporter is initialised and every reconcile pass produces a tree of spans:

entry.reconcile  (trigger=triggered|periodic)
├── entry.list_entries
│   └── grpc.BatchListEntries  ← from otelgrpc
├── entry.list_static_entries
├── entry.list_cluster_spiffeids
├── entry.build_node_map
├── entry.render_state
├── entry.diff
├── entry.delete  (count=N)
│   └── grpc.BatchDeleteEntry
├── entry.create  (count=N)
│   └── grpc.BatchCreateEntry
├── entry.update  (count=N)
│   └── grpc.BatchUpdateEntry
└── entry.status_update

When enabled: false (the default), there is zero runtime overhead — a no-op TracerProvider is installed so all instrumentation call sites are compiled in but cost nothing.

# Example controller config snippet
spec:
  tracing:
    enabled: true
    otlpEndpoint: "otel-collector.monitoring:4317"
    sampleRatio: 0.1   # 10% sampling in high-throughput clusters

Design notes

  • No-op by default: tracing is opt-in; metrics are always emitted (same model as the existing cluster_static_entry_failures counter).
  • otelgrpc on the SPIRE client: a single grpc.WithStatsHandler(otelgrpc.NewClientHandler()) call instruments all gRPC calls automatically. When tracing is off, the handler is present but the no-op provider means no spans are created.
  • pod_attestation_delay_seconds excludes AlreadyExists races: if a second controller replica creates the same entry (because leader election is off), the AlreadyExists response is not recorded as an attestation event — only genuine first-creates contribute to the SLI.
  • already_exists / not_found counters: spire_entry_write_total{code="already_exists"} on creates and code="not_found" on deletes are the fingerprint of two replicas running without leader election. Enabling leader election should drop these to zero.

Checklist

  • Metrics compile and are registered at startup
  • Unit tests for EntryWriteTotal counters (create/update/delete, OK + error codes)
  • Unit test for SPIREAPIRequestDuration observation on ListEntries
  • Integration / e2e test with a real SPIRE server
  • Grafana dashboard (out of scope for this draft)
  • CRD / config documentation update

Made with Cursor

antoinedao-cohere and others added 6 commits July 16, 2026 11:43
Add OTel SDK, OTLP/gRPC exporter, otelgrpc stats handler, and their
transitive dependencies (cenkalti/backoff, grpc-gateway, etc.) in
preparation for distributed tracing and richer gRPC instrumentation.

Bumps google.golang.org/genproto/googleapis/{api,rpc} to a newer patch
version pulled in by the OTel OTLP exporter.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: antoinedao-cohere <antoine.dao@cohere.com>
Replace the single StaticEntryFailures placeholder with a full set of
instruments covering the key observability signals identified during
investigation:

- ReconcileDuration: wall-clock duration of each full pass (kind/trigger/result)
- ReconcilePhaseDuration: per-phase breakdown (list_entries, diff, create, …)
- SPIREAPIRequestDuration: per-RPC latency (list, batch_create/update/delete)
- EntryWriteTotal: per-operation, per-gRPC-code counters (ok/already_exists/…)
- ReconcileTriggerTotal: enqueued vs. dropped trigger counts
- EntryRenderCacheTotal: render cache hit/miss ratio
- PodAttestationDelay: primary user-facing SLI (pod created → entry created)
- ReconcileBatchEntries: batch size gauge for correlating size with latency

Adds Register() to wire all metrics into a Prometheus Registerer, and
ObservePhase() as a convenience stop-timer helper for reconcile phases.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: antoinedao-cohere <antoine.dao@cohere.com>
pkg/tracing provides Init() and Tracer() as the two surfaces used by the
rest of the codebase:

- Init() wires an OTLP/gRPC batch exporter when Enabled=true, or installs
  a no-op TracerProvider when Enabled=false (the default). A no-op provider
  means all otel.Tracer().Start() call sites compile and execute with zero
  allocation or span overhead.
- Tracer() returns the global tracer scoped to the controller's
  instrumentation name, transparently delegating to whichever provider
  Init() installed.

The TracingConfig struct is added to ControllerManagerConfigurationSpec so
that the OTLP endpoint, sample ratio, and enabled flag can be set via the
controller config file. The generated DeepCopy methods are updated
accordingly.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: antoinedao-cohere <antoine.dao@cohere.com>
…eus metrics

client.go: attach otelgrpc.NewClientHandler() as a gRPC stats handler so
that every SPIRE API call automatically creates a child span under the
active reconcile span. When tracing is disabled the handler is present
but the no-op provider means no spans are allocated.

entryapi.go:
- ListEntries: time the full paginated read into SPIREAPIRequestDuration
  with the list_entries label. This directly measures the bottleneck
  phase identified in the investigation.
- BatchCreateEntry / BatchUpdateEntry / BatchDeleteEntry: time each batch
  RPC and record per-result gRPC status codes into EntryWriteTotal.
  already_exists on create and not_found on delete quantify the wasted
  work that occurs when leader election is disabled and two replicas race.

metrics_test.go: table-driven tests verifying counter increments for OK,
AlreadyExists, and NotFound result codes on create/update/delete, and
that SPIREAPIRequestDuration receives at least one observation after
ListEntries.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: antoinedao-cohere <antoine.dao@cohere.com>
…ile loop

reconciler.go (base reconciler):
- Track whether each pass was triggered or periodic via lastTrigger, and
  record it as a label on ReconcileDuration and as a span attribute.
- Count trigger events as enqueued vs. dropped in ReconcileTriggerTotal.
  A high drop rate signals that reconcile passes fill the entire GC
  interval and new pod events are being coalesced away.
- Wrap each pass in a tracing.Tracer().Start() span; the no-op provider
  makes this zero-cost when tracing is disabled.

spireentry/reconciler.go:
- Wrap each named phase (list_entries, list_static_entries,
  list_cluster_spiffeids, build_node_map, render_state, diff, delete,
  create, update, status_update) in both an ObservePhase() stop-timer
  and a child OTel span.
- Record batch sizes (delete/create/update) into ReconcileBatchEntries
  before issuing RPCs for correlation with SPIREAPIRequestDuration.
- Instrument the render cache: increment EntryRenderCacheTotal with
  hit/miss on every renderPodEntry call.
- Propagate pod.CreationTimestamp through AddDeclared → declaredEntry →
  createEntries so that PodAttestationDelay can be observed on codes.OK
  for genuinely new entries (AlreadyExists races are excluded).

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: antoinedao-cohere <antoine.dao@cohere.com>
…startup

Call metrics.Register() alongside the existing StaticEntryFailures
registration in init() so that all new histograms, counters, and gauges
are visible in the /metrics endpoint from the first scrape.

Call tracing.Init() early in run() before dialling the SPIRE server so
that the OTLP/gRPC exporter connection is established before the first
reconcile span is created. The shutdown function is deferred so the batch
exporter is flushed cleanly on exit. When Tracing.Enabled is false (the
default), Init() is a no-op — zero runtime overhead.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: antoinedao-cohere <antoine.dao@cohere.com>
@antoinedao-cohere
antoinedao-cohere force-pushed the controller-observability-improvements branch from f3cd32e to 0535ebe Compare July 16, 2026 10:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant