From 885f2be16bcb73864df4f78aa66acc061a205c70 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:43:54 +0200 Subject: [PATCH] feat(license): Prometheus gauges for state + days remaining cameleer_license_state{state=...} (one-hot per LicenseState), cameleer_license_days_remaining (negative when ABSENT/INVALID), cameleer_license_last_validated_age_seconds. Refreshed on LicenseChangedEvent and every 60s. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../server/app/license/LicenseMetrics.java | 77 +++++++++++++++++++ .../app/license/LicenseMetricsTest.java | 60 +++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMetrics.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseMetricsTest.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMetrics.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMetrics.java new file mode 100644 index 00000000..2dbf5e28 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseMetrics.java @@ -0,0 +1,77 @@ +package com.cameleer.server.app.license; + +import com.cameleer.server.core.license.LicenseGate; +import com.cameleer.server.core.license.LicenseState; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.util.EnumMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Prometheus gauges that track the live license posture. + * + * + * + *

Refreshed eagerly on {@link LicenseChangedEvent} and lazily every 60 seconds so values + * stay current even without explicit state changes (e.g. days_remaining ticks down across + * the day, validated_age grows monotonically).

+ */ +@Component +public class LicenseMetrics { + + private final LicenseGate gate; + private final LicenseRepository repo; + private final String tenantId; + + private final Map> stateGauges = new EnumMap<>(LicenseState.class); + private final AtomicReference daysRemaining = new AtomicReference<>(0.0); + private final AtomicReference validatedAge = new AtomicReference<>(0.0); + + public LicenseMetrics(LicenseGate gate, LicenseRepository repo, MeterRegistry meters, + @Value("${cameleer.server.tenant.id:default}") String tenantId) { + this.gate = gate; + this.repo = repo; + this.tenantId = tenantId; + for (var s : LicenseState.values()) { + var ref = new AtomicReference<>(0.0); + stateGauges.put(s, ref); + Gauge.builder("cameleer_license_state", ref, AtomicReference::get) + .tag("state", s.name()) + .register(meters); + } + Gauge.builder("cameleer_license_days_remaining", daysRemaining, AtomicReference::get) + .register(meters); + Gauge.builder("cameleer_license_last_validated_age_seconds", validatedAge, AtomicReference::get) + .register(meters); + } + + @EventListener(LicenseChangedEvent.class) + @Scheduled(fixedDelay = 60_000) + public void refresh() { + var state = gate.getState(); + for (var s : LicenseState.values()) { + stateGauges.get(s).set(s == state ? 1.0 : 0.0); + } + var info = gate.getCurrent(); + daysRemaining.set(info == null + ? -1.0 + : (double) Duration.between(Instant.now(), info.expiresAt()).toDays()); + repo.findByTenantId(tenantId).ifPresent(rec -> + validatedAge.set((double) Duration.between(rec.lastValidatedAt(), Instant.now()).toSeconds())); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseMetricsTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseMetricsTest.java new file mode 100644 index 00000000..0ba28eb1 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseMetricsTest.java @@ -0,0 +1,60 @@ +package com.cameleer.server.app.license; + +import com.cameleer.server.core.license.LicenseGate; +import com.cameleer.server.core.license.LicenseInfo; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LicenseMetricsTest { + + @Test + void absentState_setsAbsentGaugeTo1AndDaysRemainingTo_minusOne() { + LicenseGate gate = new LicenseGate(); + LicenseRepository repo = mock(LicenseRepository.class); + when(repo.findByTenantId("default")).thenReturn(Optional.empty()); + SimpleMeterRegistry meters = new SimpleMeterRegistry(); + + var metrics = new LicenseMetrics(gate, repo, meters, "default"); + metrics.refresh(); + + assertThat(meters.find("cameleer_license_state").tag("state", "ABSENT").gauge().value()) + .isEqualTo(1.0); + assertThat(meters.find("cameleer_license_state").tag("state", "ACTIVE").gauge().value()) + .isEqualTo(0.0); + assertThat(meters.find("cameleer_license_days_remaining").gauge().value()) + .isEqualTo(-1.0); + } + + @Test + void activeState_reportsDaysRemaining() { + LicenseGate gate = new LicenseGate(); + gate.load(new LicenseInfo(UUID.randomUUID(), "default", "test", + Map.of(), + Instant.now().minusSeconds(86400), + Instant.now().plus(10, ChronoUnit.DAYS), + 0)); + LicenseRepository repo = mock(LicenseRepository.class); + when(repo.findByTenantId("default")).thenReturn(Optional.empty()); + SimpleMeterRegistry meters = new SimpleMeterRegistry(); + + var metrics = new LicenseMetrics(gate, repo, meters, "default"); + metrics.refresh(); + + assertThat(meters.find("cameleer_license_state").tag("state", "ACTIVE").gauge().value()) + .isEqualTo(1.0); + assertThat(meters.find("cameleer_license_state").tag("state", "ABSENT").gauge().value()) + .isEqualTo(0.0); + assertThat(meters.find("cameleer_license_days_remaining").gauge().value()) + .isBetween(9.0, 10.5); + } +}