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