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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 15:43:54 +02:00
parent 945ecd78cf
commit 885f2be16b
2 changed files with 137 additions and 0 deletions

View File

@@ -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.
*
* <ul>
* <li>{@code cameleer_license_state{state=...}} — one-hot per {@link LicenseState}, exactly
* one tag value carries 1.0 at any time.</li>
* <li>{@code cameleer_license_days_remaining} — days until {@code expiresAt}; negative
* (-1.0) when ABSENT/INVALID (no license loaded).</li>
* <li>{@code cameleer_license_last_validated_age_seconds} — seconds since the persisted
* {@code last_validated_at}; 0 when there is no DB row.</li>
* </ul>
*
* <p>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).</p>
*/
@Component
public class LicenseMetrics {
private final LicenseGate gate;
private final LicenseRepository repo;
private final String tenantId;
private final Map<LicenseState, AtomicReference<Double>> stateGauges = new EnumMap<>(LicenseState.class);
private final AtomicReference<Double> daysRemaining = new AtomicReference<>(0.0);
private final AtomicReference<Double> 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()));
}
}

View File

@@ -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);
}
}