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.
+ *
+ *
+ * - {@code cameleer_license_state{state=...}} — one-hot per {@link LicenseState}, exactly
+ * one tag value carries 1.0 at any time.
+ * - {@code cameleer_license_days_remaining} — days until {@code expiresAt}; negative
+ * (-1.0) when ABSENT/INVALID (no license loaded).
+ * - {@code cameleer_license_last_validated_age_seconds} — seconds since the persisted
+ * {@code last_validated_at}; 0 when there is no DB row.
+ *
+ *
+ * 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);
+ }
+}