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:
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user