1use std::net::SocketAddr;
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::time::{Duration, Instant};
7
8use axum::extract::State;
9use axum::http::StatusCode;
10use axum::response::IntoResponse;
11use axum::routing::get;
12use axum::{Router, serve};
13use opentelemetry::KeyValue;
14use opentelemetry::metrics::{Counter, Histogram, Meter, MeterProvider, UpDownCounter};
15use opentelemetry_otlp::{MetricExporter, Protocol, WithExportConfig};
16use opentelemetry_sdk::Resource;
17use opentelemetry_sdk::logs::SdkLoggerProvider;
18use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
19use tokio::net::TcpListener;
20
21const SERVICE_NAME: &str = "pgroles-operator";
22
23#[derive(Clone)]
24pub struct OperatorObservability {
25 ready: Arc<AtomicBool>,
26 metrics: Option<Arc<Metrics>>,
27 logger_provider: Option<SdkLoggerProvider>,
28}
29
30struct Metrics {
31 provider: SdkMeterProvider,
32 reconcile_total: Counter<u64>,
33 reconcile_duration_ms: Histogram<u64>,
34 reconcile_inflight: UpDownCounter<i64>,
35 inspect_duration_ms: Histogram<u64>,
36 inspect_items_total: Counter<u64>,
37 wildcard_grantability_queries_total: Counter<u64>,
38 wildcard_unsatisfied_grants_total: Counter<u64>,
39 plan_total: Counter<u64>,
40 plan_changes_total: Counter<u64>,
41 lock_contention_total: Counter<u64>,
42 policy_conflicts_total: Counter<u64>,
43 invalid_spec_total: Counter<u64>,
44 deprecated_approval_unset_total: Counter<u64>,
45 database_connection_failures_total: Counter<u64>,
46 apply_total: Counter<u64>,
47 apply_statements_total: Counter<u64>,
48 ephemeral_transition_total: Counter<u64>,
49 ephemeral_failure_total: Counter<u64>,
50 ephemeral_retained_memberships_total: Counter<u64>,
51 ephemeral_expiry_lag_ms: Histogram<u64>,
52 ephemeral_role_retirement_blocked_total: Counter<u64>,
53 ephemeral_cached_requests: Histogram<u64>,
54 ephemeral_relevant_requests: Histogram<u64>,
55 ephemeral_reconcile_duration_ms: Histogram<u64>,
56 ephemeral_reconcile_inflight: UpDownCounter<i64>,
57}
58
59pub struct ReconcileGuard {
60 metrics: Option<Arc<Metrics>>,
61 started_at: Instant,
62}
63
64pub struct EphemeralReconcileGuard {
65 metrics: Option<Arc<Metrics>>,
66 started_at: Instant,
67 kind: &'static str,
68 request_count_bucket: &'static str,
69}
70
71impl OperatorObservability {
72 pub fn from_env() -> anyhow::Result<Self> {
73 Ok(Self {
74 ready: Arc::new(AtomicBool::new(false)),
75 metrics: init_metrics_from_env()?,
76 logger_provider: None,
77 })
78 }
79
80 pub fn with_logger_provider(mut self, provider: Option<SdkLoggerProvider>) -> Self {
81 self.logger_provider = provider;
82 self
83 }
84
85 pub fn mark_ready(&self) {
86 self.ready.store(true, Ordering::Relaxed);
87 }
88
89 pub fn mark_not_ready(&self) {
90 self.ready.store(false, Ordering::Relaxed);
91 }
92
93 pub fn start_reconcile(&self) -> ReconcileGuard {
94 if let Some(metrics) = &self.metrics {
95 metrics.reconcile_inflight.add(1, &[]);
96 ReconcileGuard {
97 metrics: Some(metrics.clone()),
98 started_at: Instant::now(),
99 }
100 } else {
101 ReconcileGuard {
102 metrics: None,
103 started_at: Instant::now(),
104 }
105 }
106 }
107
108 pub fn record_database_connection_failure(&self) {
109 if let Some(metrics) = &self.metrics {
110 metrics.database_connection_failures_total.add(1, &[]);
111 }
112 }
113
114 pub fn record_policy_conflict(&self) {
115 if let Some(metrics) = &self.metrics {
116 metrics.policy_conflicts_total.add(1, &[]);
117 }
118 }
119
120 pub fn record_lock_contention(&self) {
121 if let Some(metrics) = &self.metrics {
122 metrics.lock_contention_total.add(1, &[]);
123 }
124 }
125
126 pub fn record_plan_result(&self, result: &str) {
127 if let Some(metrics) = &self.metrics {
128 metrics
129 .plan_total
130 .add(1, &[KeyValue::new("result", result.to_string())]);
131 }
132 }
133
134 pub fn record_planned_changes(&self, changes: usize) {
135 if changes == 0 {
136 return;
137 }
138 if let Some(metrics) = &self.metrics {
139 metrics.plan_changes_total.add(changes as u64, &[]);
140 }
141 }
142
143 pub fn record_invalid_spec(&self) {
144 if let Some(metrics) = &self.metrics {
145 metrics.invalid_spec_total.add(1, &[]);
146 }
147 }
148
149 pub fn record_deprecated_approval_unset(&self, inferred: &str) {
153 if let Some(metrics) = &self.metrics {
154 metrics
155 .deprecated_approval_unset_total
156 .add(1, &[KeyValue::new("inferred", inferred.to_string())]);
157 }
158 }
159
160 pub fn record_inspection(&self, stats: &pgroles_inspect::InspectionStats) {
161 let Some(metrics) = &self.metrics else {
162 return;
163 };
164
165 for (phase, duration) in &stats.phase_durations {
166 metrics.inspect_duration_ms.record(
167 duration.as_millis() as u64,
168 &[KeyValue::new("phase", *phase)],
169 );
170 }
171
172 for (kind, count) in [
173 ("roles", stats.roles),
174 ("memberships", stats.memberships),
175 ("schemas", stats.schemas),
176 ("grants", stats.grants),
177 ("default_privileges", stats.default_privileges),
178 (
179 "wildcard_configured_grants",
180 stats.wildcard.configured_grants,
181 ),
182 (
183 "wildcard_configured_scopes",
184 stats.wildcard.configured_scopes,
185 ),
186 (
187 "wildcard_inventory_objects",
188 stats.wildcard.inventory_objects,
189 ),
190 (
191 "wildcard_unsatisfied_scopes",
192 stats.wildcard.unsatisfied_scopes,
193 ),
194 (
195 "wildcard_grantability_objects",
196 stats.wildcard.grantability_objects,
197 ),
198 ] {
199 if count > 0 {
200 metrics
201 .inspect_items_total
202 .add(count as u64, &[KeyValue::new("kind", kind)]);
203 }
204 }
205
206 if stats.wildcard.grantability_queries > 0 {
207 metrics
208 .wildcard_grantability_queries_total
209 .add(stats.wildcard.grantability_queries as u64, &[]);
210 }
211 if stats.wildcard.unsatisfied_grants > 0 {
212 metrics
213 .wildcard_unsatisfied_grants_total
214 .add(stats.wildcard.unsatisfied_grants as u64, &[]);
215 }
216 }
217
218 pub fn record_apply_result(&self, result: &str) {
219 if let Some(metrics) = &self.metrics {
220 metrics
221 .apply_total
222 .add(1, &[KeyValue::new("result", result.to_string())]);
223 }
224 }
225
226 pub fn record_apply_statements(&self, statements: usize) {
227 if statements == 0 {
228 return;
229 }
230 if let Some(metrics) = &self.metrics {
231 metrics.apply_statements_total.add(statements as u64, &[]);
232 }
233 }
234
235 pub fn record_ephemeral_transition(&self, phase: &str, reason: &str) {
236 if let Some(metrics) = &self.metrics {
237 metrics.ephemeral_transition_total.add(
238 1,
239 &[
240 KeyValue::new("phase", phase.to_string()),
241 KeyValue::new("reason", reason.to_string()),
242 ],
243 );
244 if matches!(phase, "Failed" | "Denied" | "ApprovalExpired") {
245 metrics
246 .ephemeral_failure_total
247 .add(1, &[KeyValue::new("reason", reason.to_string())]);
248 }
249 }
250 }
251
252 pub fn record_ephemeral_retained_memberships(&self, count: usize) {
253 if count == 0 {
254 return;
255 }
256 if let Some(metrics) = &self.metrics {
257 metrics
258 .ephemeral_retained_memberships_total
259 .add(count as u64, &[]);
260 }
261 }
262
263 pub fn record_ephemeral_expiry_lag(&self, lag: Duration) {
264 if let Some(metrics) = &self.metrics {
265 metrics
266 .ephemeral_expiry_lag_ms
267 .record(lag.as_millis() as u64, &[]);
268 }
269 }
270
271 pub fn record_ephemeral_role_retirement_blocked(&self) {
272 if let Some(metrics) = &self.metrics {
273 metrics.ephemeral_role_retirement_blocked_total.add(1, &[]);
274 }
275 }
276
277 pub fn record_ephemeral_relevant_requests(&self, lookup: &'static str, count: usize) {
278 if let Some(metrics) = &self.metrics {
279 metrics
280 .ephemeral_relevant_requests
281 .record(count as u64, &[KeyValue::new("lookup", lookup)]);
282 }
283 }
284
285 pub fn start_ephemeral_reconcile(
286 &self,
287 kind: &'static str,
288 cached_requests: usize,
289 ) -> EphemeralReconcileGuard {
290 if let Some(metrics) = &self.metrics {
291 metrics
292 .ephemeral_cached_requests
293 .record(cached_requests as u64, &[]);
294 metrics
295 .ephemeral_reconcile_inflight
296 .add(1, &[KeyValue::new("kind", kind)]);
297 }
298 EphemeralReconcileGuard {
299 metrics: self.metrics.clone(),
300 started_at: Instant::now(),
301 kind,
302 request_count_bucket: request_count_bucket(cached_requests),
303 }
304 }
305
306 pub fn shutdown(&self) -> anyhow::Result<()> {
307 if let Some(metrics) = &self.metrics {
308 metrics.provider.shutdown()?;
309 }
310 if let Some(provider) = &self.logger_provider {
311 provider.shutdown()?;
312 }
313 Ok(())
314 }
315}
316
317impl ReconcileGuard {
318 pub fn record_result(self, result: &str, reason: &str) {
319 if let Some(metrics) = &self.metrics {
320 metrics.reconcile_total.add(
321 1,
322 &[
323 KeyValue::new("result", result.to_string()),
324 KeyValue::new("reason", reason.to_string()),
325 ],
326 );
327 metrics
328 .reconcile_duration_ms
329 .record(self.started_at.elapsed().as_millis() as u64, &[]);
330 }
331 }
332}
333
334impl Drop for ReconcileGuard {
335 fn drop(&mut self) {
336 if let Some(metrics) = &self.metrics {
337 metrics.reconcile_inflight.add(-1, &[]);
338 }
339 }
340}
341
342impl Drop for EphemeralReconcileGuard {
343 fn drop(&mut self) {
344 if let Some(metrics) = &self.metrics {
345 metrics.ephemeral_reconcile_duration_ms.record(
346 self.started_at.elapsed().as_millis() as u64,
347 &[
348 KeyValue::new("kind", self.kind),
349 KeyValue::new("request_count", self.request_count_bucket),
350 ],
351 );
352 metrics
353 .ephemeral_reconcile_inflight
354 .add(-1, &[KeyValue::new("kind", self.kind)]);
355 }
356 }
357}
358
359fn request_count_bucket(count: usize) -> &'static str {
360 match count {
361 0 => "0",
362 1..=10 => "1-10",
363 11..=100 => "11-100",
364 101..=1_000 => "101-1000",
365 _ => "1001+",
366 }
367}
368
369pub async fn serve_health(
370 bind_addr: SocketAddr,
371 observability: OperatorObservability,
372) -> anyhow::Result<()> {
373 let listener = TcpListener::bind(bind_addr).await?;
374 let app = Router::new()
375 .route("/livez", get(livez))
376 .route("/readyz", get(readyz))
377 .with_state(observability);
378
379 serve(listener, app).await?;
380 Ok(())
381}
382
383fn init_metrics_from_env() -> anyhow::Result<Option<Arc<Metrics>>> {
384 if !otel_metrics_enabled() {
385 return Ok(None);
386 }
387
388 let exporter = MetricExporter::builder()
389 .with_tonic()
390 .with_protocol(Protocol::Grpc)
391 .build()?;
392
393 let reader = PeriodicReader::builder(exporter).build();
394 let provider = SdkMeterProvider::builder()
395 .with_reader(reader)
396 .with_resource(
397 Resource::builder_empty()
398 .with_attributes([
399 KeyValue::new("service.name", SERVICE_NAME),
400 KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
401 ])
402 .build(),
403 )
404 .build();
405
406 let meter = provider.meter(SERVICE_NAME);
407 Ok(Some(Arc::new(Metrics::new(provider, meter))))
408}
409
410pub fn init_log_provider_from_env() -> anyhow::Result<Option<SdkLoggerProvider>> {
414 let logs_exporter = std::env::var("OTEL_LOGS_EXPORTER").ok();
415 if matches!(logs_exporter.as_deref(), Some("none")) {
416 return Ok(None);
417 }
418 let endpoint_configured = std::env::var_os("OTEL_EXPORTER_OTLP_ENDPOINT").is_some()
419 || std::env::var_os("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT").is_some();
420 if !endpoint_configured && !matches!(logs_exporter.as_deref(), Some("otlp")) {
421 return Ok(None);
422 }
423
424 let exporter = opentelemetry_otlp::LogExporter::builder()
425 .with_tonic()
426 .with_protocol(Protocol::Grpc)
427 .build()?;
428 let provider = SdkLoggerProvider::builder()
429 .with_resource(
430 Resource::builder_empty()
431 .with_attributes([
432 KeyValue::new("service.name", SERVICE_NAME),
433 KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
434 ])
435 .build(),
436 )
437 .with_batch_exporter(exporter)
438 .build();
439 Ok(Some(provider))
440}
441
442fn otel_metrics_enabled() -> bool {
443 let metrics_exporter = std::env::var("OTEL_METRICS_EXPORTER").ok();
444 if matches!(metrics_exporter.as_deref(), Some("none")) {
445 return false;
446 }
447
448 std::env::var_os("OTEL_EXPORTER_OTLP_ENDPOINT").is_some()
449 || std::env::var_os("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT").is_some()
450}
451
452impl Metrics {
453 fn new(provider: SdkMeterProvider, meter: Meter) -> Self {
454 Self {
455 provider,
456 reconcile_total: meter
457 .u64_counter("pgroles.reconcile.total")
458 .with_description("Total reconciliations by result and reason")
459 .build(),
460 reconcile_duration_ms: meter
461 .u64_histogram("pgroles.reconcile.duration")
462 .with_unit("ms")
463 .with_description("Reconciliation duration in milliseconds")
464 .build(),
465 reconcile_inflight: meter
466 .i64_up_down_counter("pgroles.reconcile.inflight")
467 .with_description("In-flight reconciliations")
468 .build(),
469 inspect_duration_ms: meter
470 .u64_histogram("pgroles.inspect.duration")
471 .with_unit("ms")
472 .with_description("Database inspection phase duration in milliseconds")
473 .build(),
474 inspect_items_total: meter
475 .u64_counter("pgroles.inspect.items")
476 .with_description("Database inspection objects observed by kind")
477 .build(),
478 wildcard_grantability_queries_total: meter
479 .u64_counter("pgroles.wildcard.grantability_queries")
480 .with_description("Wildcard grantability catalog queries")
481 .build(),
482 wildcard_unsatisfied_grants_total: meter
483 .u64_counter("pgroles.wildcard.unsatisfied_grants")
484 .with_description("Wildcard grants missing privileges before grantability checks")
485 .build(),
486 plan_total: meter
487 .u64_counter("pgroles.plan.total")
488 .with_description("Successful plan-mode reconciliations by result")
489 .build(),
490 plan_changes_total: meter
491 .u64_counter("pgroles.plan.changes")
492 .with_description("Planned changes discovered during plan-mode reconciliations")
493 .build(),
494 lock_contention_total: meter
495 .u64_counter("pgroles.lock_contention.total")
496 .with_description("Reconciliations delayed by per-database lock contention")
497 .build(),
498 policy_conflicts_total: meter
499 .u64_counter("pgroles.policy.conflicts")
500 .with_description("Conflicting policies targeting the same database")
501 .build(),
502 invalid_spec_total: meter
503 .u64_counter("pgroles.invalid_spec.total")
504 .with_description("Invalid PostgresPolicy specifications")
505 .build(),
506 deprecated_approval_unset_total: meter
507 .u64_counter("pgroles.deprecated.approval_unset")
508 .with_description(
509 "Reconciles of a PostgresPolicy that omits spec.approval and relies on the \
510 deprecated inference from spec.mode",
511 )
512 .build(),
513 database_connection_failures_total: meter
514 .u64_counter("pgroles.database.connection_failures")
515 .with_description("Database connection failures during reconciliation")
516 .build(),
517 apply_total: meter
518 .u64_counter("pgroles.apply.total")
519 .with_description("Apply transaction outcomes")
520 .build(),
521 apply_statements_total: meter
522 .u64_counter("pgroles.apply.statements")
523 .with_description("SQL statements executed during successful applies")
524 .build(),
525 ephemeral_transition_total: meter
526 .u64_counter("pgroles.ephemeral_access.transitions")
527 .with_description("Ephemeral access lifecycle transitions by phase and reason")
528 .build(),
529 ephemeral_failure_total: meter
530 .u64_counter("pgroles.ephemeral_access.failures")
531 .with_description("Terminal ephemeral access failures by reason")
532 .build(),
533 ephemeral_retained_memberships_total: meter
534 .u64_counter("pgroles.ephemeral_access.retained_memberships")
535 .with_description("Ephemeral memberships retained because they became durable")
536 .build(),
537 ephemeral_expiry_lag_ms: meter
538 .u64_histogram("pgroles.ephemeral_access.expiry_lag")
539 .with_unit("ms")
540 .with_description("Delay between absolute expiry and revocation processing")
541 .build(),
542 ephemeral_role_retirement_blocked_total: meter
543 .u64_counter("pgroles.ephemeral_access.role_retirement_blocked")
544 .with_description("Role retirements blocked by active access requests")
545 .build(),
546 ephemeral_cached_requests: meter
547 .u64_histogram("pgroles.ephemeral_access.cached_requests")
548 .with_description("Request-cache size sampled at reconcile start")
549 .build(),
550 ephemeral_relevant_requests: meter
551 .u64_histogram("pgroles.ephemeral_access.relevant_requests")
552 .with_description("Requests returned by an indexed lookup")
553 .build(),
554 ephemeral_reconcile_duration_ms: meter
555 .u64_histogram("pgroles.ephemeral_access.reconcile.duration")
556 .with_unit("ms")
557 .with_description("Ephemeral reconcile duration by kind and request-count bucket")
558 .build(),
559 ephemeral_reconcile_inflight: meter
560 .i64_up_down_counter("pgroles.ephemeral_access.reconcile.inflight")
561 .with_description("In-flight ephemeral reconciliations by resource kind")
562 .build(),
563 }
564 }
565}
566
567async fn livez() -> &'static str {
568 "ok"
569}
570
571async fn readyz(State(observability): State<OperatorObservability>) -> impl IntoResponse {
572 if observability.ready.load(Ordering::Relaxed) {
573 (StatusCode::OK, "ready")
574 } else {
575 (StatusCode::SERVICE_UNAVAILABLE, "not ready")
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use std::sync::{Arc, Mutex};
582 use std::time::Duration;
583
584 use axum::extract::State;
585 use axum::http::StatusCode;
586 use axum::response::IntoResponse;
587 use opentelemetry::metrics::MeterProvider;
588 use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData, ResourceMetrics};
589 use opentelemetry_sdk::metrics::{InMemoryMetricExporter, PeriodicReader, SdkMeterProvider};
590
591 use super::{
592 Metrics, OperatorObservability, ReconcileGuard, SERVICE_NAME, livez, otel_metrics_enabled,
593 readyz,
594 };
595
596 static ENV_LOCK: Mutex<()> = Mutex::new(());
597
598 fn test_observability() -> (
599 OperatorObservability,
600 SdkMeterProvider,
601 InMemoryMetricExporter,
602 ) {
603 let exporter = InMemoryMetricExporter::default();
604 let provider = SdkMeterProvider::builder()
605 .with_reader(PeriodicReader::builder(exporter.clone()).build())
606 .build();
607 let meter = provider.meter(SERVICE_NAME);
608 let observability = OperatorObservability {
609 ready: Arc::new(std::sync::atomic::AtomicBool::new(false)),
610 metrics: Some(Arc::new(Metrics::new(provider.clone(), meter))),
611 logger_provider: None,
612 };
613
614 (observability, provider, exporter)
615 }
616
617 fn metric_exists(metrics: &[ResourceMetrics], name: &str) -> bool {
618 metrics.iter().any(|resource_metrics| {
619 resource_metrics
620 .scope_metrics()
621 .flat_map(|scope_metrics| scope_metrics.metrics())
622 .any(|metric| metric.name() == name)
623 })
624 }
625
626 fn u64_sum_value(metrics: &[ResourceMetrics], name: &str) -> Option<u64> {
627 let mut found = false;
628 let total = metrics
629 .iter()
630 .flat_map(|resource_metrics| resource_metrics.scope_metrics())
631 .flat_map(|scope_metrics| scope_metrics.metrics())
632 .filter(|metric| metric.name() == name)
633 .filter_map(|metric| match metric.data() {
634 AggregatedMetrics::U64(MetricData::Sum(sum)) => {
635 found = true;
636 Some(
637 sum.data_points()
638 .map(|data_point| data_point.value())
639 .sum::<u64>(),
640 )
641 }
642 _ => None,
643 })
644 .sum();
645
646 found.then_some(total)
647 }
648
649 fn i64_sum_value(metrics: &[ResourceMetrics], name: &str) -> Option<i64> {
650 metrics
651 .iter()
652 .flat_map(|resource_metrics| resource_metrics.scope_metrics())
653 .flat_map(|scope_metrics| scope_metrics.metrics())
654 .find(|metric| metric.name() == name)
655 .and_then(|metric| match metric.data() {
656 AggregatedMetrics::I64(MetricData::Sum(sum)) => sum
657 .data_points()
658 .next()
659 .map(|data_point| data_point.value()),
660 _ => None,
661 })
662 }
663
664 #[test]
665 fn otel_metrics_stay_disabled_without_endpoint() {
666 let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
667 unsafe {
668 std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
669 std::env::remove_var("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT");
670 std::env::remove_var("OTEL_METRICS_EXPORTER");
671 }
672 assert!(!otel_metrics_enabled());
673 }
674
675 #[test]
676 fn otel_metrics_enable_with_explicit_endpoint() {
677 let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
678 unsafe {
679 std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4317");
680 std::env::remove_var("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT");
681 std::env::remove_var("OTEL_METRICS_EXPORTER");
682 }
683 assert!(otel_metrics_enabled());
684 unsafe {
685 std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
686 }
687 }
688
689 #[tokio::test]
690 async fn health_endpoints_reflect_readiness() {
691 let (observability, _provider, _exporter) = test_observability();
692
693 assert_eq!(livez().await, "ok");
694
695 let not_ready = readyz(State(observability.clone())).await.into_response();
696 assert_eq!(not_ready.status(), StatusCode::SERVICE_UNAVAILABLE);
697
698 observability.mark_ready();
699 let ready = readyz(State(observability)).await.into_response();
700 assert_eq!(ready.status(), StatusCode::OK);
701 }
702
703 #[test]
704 fn metrics_are_recorded_and_flushed() {
705 let (observability, provider, exporter) = test_observability();
706
707 let guard: ReconcileGuard = observability.start_reconcile();
708 observability.record_lock_contention();
709 observability.record_policy_conflict();
710 observability.record_invalid_spec();
711 observability.record_database_connection_failure();
712 observability.record_inspection(&pgroles_inspect::InspectionStats {
713 roles: 3,
714 memberships: 2,
715 schemas: 1,
716 grants: 5,
717 default_privileges: 1,
718 phase_durations: [
719 ("roles", Duration::from_millis(4)),
720 ("object_privileges", Duration::from_millis(12)),
721 ]
722 .into_iter()
723 .collect(),
724 wildcard: pgroles_inspect::WildcardInspectionStats {
725 configured_grants: 2,
726 configured_scopes: 1,
727 inventory_objects: 100,
728 unsatisfied_grants: 1,
729 unsatisfied_scopes: 1,
730 grantability_queries: 1,
731 grantability_objects: 3,
732 },
733 });
734 observability.record_plan_result("drift");
735 observability.record_planned_changes(2);
736 observability.record_apply_result("success");
737 observability.record_apply_statements(4);
738 observability.record_ephemeral_transition("Active", "MembershipsGranted");
739 observability.record_ephemeral_transition("Failed", "InvalidRequestState");
740 observability.record_ephemeral_retained_memberships(2);
741 observability.record_ephemeral_expiry_lag(Duration::from_millis(250));
742 observability.record_ephemeral_role_retirement_blocked();
743 observability.record_ephemeral_relevant_requests("effective_graph", 3);
744 drop(observability.start_ephemeral_reconcile("access_request", 250));
745 guard.record_result("conflict", "ConflictingPolicy");
746
747 provider.force_flush().expect("flush should succeed");
748
749 let metrics = exporter
750 .get_finished_metrics()
751 .expect("metrics should be exported");
752
753 assert!(metric_exists(&metrics, "pgroles.reconcile.total"));
754 assert!(metric_exists(&metrics, "pgroles.reconcile.duration"));
755 assert!(metric_exists(&metrics, "pgroles.inspect.duration"));
756 assert_eq!(u64_sum_value(&metrics, "pgroles.inspect.items"), Some(119));
757 assert_eq!(
758 u64_sum_value(&metrics, "pgroles.ephemeral_access.transitions"),
759 Some(2)
760 );
761 assert_eq!(
762 u64_sum_value(&metrics, "pgroles.ephemeral_access.failures"),
763 Some(1)
764 );
765 assert_eq!(
766 u64_sum_value(&metrics, "pgroles.ephemeral_access.retained_memberships"),
767 Some(2)
768 );
769 assert!(metric_exists(
770 &metrics,
771 "pgroles.ephemeral_access.expiry_lag"
772 ));
773 assert_eq!(
774 u64_sum_value(&metrics, "pgroles.ephemeral_access.role_retirement_blocked"),
775 Some(1)
776 );
777 assert!(metric_exists(
778 &metrics,
779 "pgroles.ephemeral_access.cached_requests"
780 ));
781 assert!(metric_exists(
782 &metrics,
783 "pgroles.ephemeral_access.relevant_requests"
784 ));
785 assert!(metric_exists(
786 &metrics,
787 "pgroles.ephemeral_access.reconcile.duration"
788 ));
789 assert_eq!(
790 u64_sum_value(&metrics, "pgroles.wildcard.grantability_queries"),
791 Some(1)
792 );
793 assert_eq!(
794 u64_sum_value(&metrics, "pgroles.wildcard.unsatisfied_grants"),
795 Some(1)
796 );
797 assert_eq!(u64_sum_value(&metrics, "pgroles.plan.total"), Some(1));
798 assert_eq!(u64_sum_value(&metrics, "pgroles.plan.changes"), Some(2));
799 assert_eq!(
800 u64_sum_value(&metrics, "pgroles.lock_contention.total"),
801 Some(1)
802 );
803 assert_eq!(u64_sum_value(&metrics, "pgroles.policy.conflicts"), Some(1));
804 assert_eq!(
805 u64_sum_value(&metrics, "pgroles.invalid_spec.total"),
806 Some(1)
807 );
808 assert_eq!(
809 u64_sum_value(&metrics, "pgroles.database.connection_failures"),
810 Some(1)
811 );
812 assert_eq!(u64_sum_value(&metrics, "pgroles.apply.statements"), Some(4));
813 assert_eq!(
814 i64_sum_value(&metrics, "pgroles.reconcile.inflight"),
815 Some(0)
816 );
817 }
818}