This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch UNOMI-958-usage-rest
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit c93685526844a5ef4196cde93cba15e1b9ceb3ae
Author: Serge Huber <[email protected]>
AuthorDate: Wed Jul 8 00:40:06 2026 +0200

    UNOMI-958: Expose per-tenant usage over REST and remove dead quota code
    
    Operators and upstream control planes need read-only usage metrics per 
tenant.
    Unomi measures usage; plan limits stay outside the CDP. This adds GET 
/tenants/{id}/usage,
    replaces internal monitoring/quota services, and hardens tenant-scoped REST 
security.
---
 .../apache/unomi/api/tenants/ResourceQuota.java    | 258 ---------------------
 .../java/org/apache/unomi/api/tenants/Tenant.java  |  92 +++-----
 .../org/apache/unomi/api/tenants/TenantUsage.java  | 108 +++++++++
 .../unomi/api/tenants/TenantUsageService.java      |  40 ++++
 .../java/org/apache/unomi/itests/TenantIT.java     |  34 ++-
 manual/src/main/asciidoc/multitenancy.adoc         |   8 +-
 rest/pom.xml                                       |  10 +
 .../impl/DefaultRestAuthenticationConfig.java      |   1 +
 .../apache/unomi/rest/security/SecurityFilter.java | 105 ++++++---
 .../apache/unomi/rest/tenants/TenantEndpoint.java  |  37 +++
 .../unomi/rest/security/SecurityFilterTest.java    | 137 +++++++++++
 .../common/security/KarafSecurityService.java      |   2 +-
 .../common/security/KarafSecurityServiceTest.java  |  27 +++
 .../unomi/services/impl/tenants/TenantMetrics.java |  59 -----
 .../impl/tenants/TenantMonitoringService.java      | 187 ---------------
 .../services/impl/tenants/TenantQuotaService.java  | 180 --------------
 .../unomi/services/impl/tenants/TenantUsage.java   |  59 -----
 .../impl/tenants/TenantUsageServiceImpl.java       | 250 ++++++++++++++++++++
 .../resources/OSGI-INF/blueprint/blueprint.xml     |  22 +-
 .../impl/tenants/TenantQuotaServiceTest.java       |  85 -------
 .../impl/tenants/TenantUsageServiceImplTest.java   | 163 +++++++++++++
 .../dev/commands/tenants/TenantCrudCommand.java    |   7 +-
 22 files changed, 908 insertions(+), 963 deletions(-)

diff --git a/api/src/main/java/org/apache/unomi/api/tenants/ResourceQuota.java 
b/api/src/main/java/org/apache/unomi/api/tenants/ResourceQuota.java
deleted file mode 100644
index 6d9e3359c..000000000
--- a/api/src/main/java/org/apache/unomi/api/tenants/ResourceQuota.java
+++ /dev/null
@@ -1,258 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.unomi.api.tenants;
-
-import java.util.HashMap;
-import java.util.Map;
-
-/**
- * Defines resource quotas and limits for a tenant.
- * This class manages various resource constraints to ensure fair usage and 
prevent abuse.
- * Each quota represents a maximum limit that the tenant cannot exceed.
- * When a quota is reached, the system will prevent further resource 
allocation until
- * resources are freed or the quota is increased.
- */
-public class ResourceQuota {
-    /**
-     * The maximum number of profiles that can be stored for this tenant.
-     * When this limit is reached, attempts to create new profiles will be 
rejected.
-     */
-    private long maxProfiles;
-
-    /**
-     * The maximum number of events that can be processed per time period for 
this tenant.
-     * Events beyond this limit will be rejected until the next period begins.
-     */
-    private long maxEvents;
-
-    /**
-     * The maximum number of rules that can be defined for this tenant.
-     * Attempts to create rules beyond this limit will be rejected.
-     */
-    private long maxRules;
-
-    /**
-     * The maximum number of segments that can be defined for this tenant.
-     * Attempts to create segments beyond this limit will be rejected.
-     */
-    private long maxSegments;
-
-    /**
-     * The maximum storage size in bytes that this tenant can use.
-     * This includes all data associated with the tenant including profiles,
-     * events, rules, and other stored data.
-     */
-    private long maxStorageSize;
-
-    /**
-     * The maximum number of concurrent API requests that can be processed
-     * for this tenant. Additional requests will be rejected with a 429 status
-     * until ongoing requests complete.
-     */
-    private int maxConcurrentRequests;
-
-    /**
-     * The maximum number of API keys (both public and private) that can be
-     * generated for this tenant. This includes both active and historical keys
-     * stored for auditing purposes.
-     */
-    private int maxApiKeys;
-
-    /**
-     * The maximum number of days that data will be retained for this tenant.
-     * Data older than this period will be automatically purged from the 
system.
-     * A value of 0 indicates no automatic purging.
-     */
-    private long maxDataRetentionDays;
-
-    /**
-     * The maximum number of API requests that can be made per time period
-     * for this tenant. Requests beyond this limit will be rejected with
-     * a 429 status until the next period begins.
-     */
-    private long maxRequests;
-
-    /**
-     * Custom quota limits that can be defined for tenant-specific needs.
-     * The map keys represent the quota type and the values represent the 
limits.
-     * These quotas can be used to limit custom resources or actions specific
-     * to certain tenant use cases.
-     */
-    private Map<String, Long> customQuotas = new HashMap<>();
-
-    /**
-     * Gets the maximum number of profiles allowed for the tenant.
-     * @return the maximum number of profiles
-     */
-    public long getMaxProfiles() {
-        return maxProfiles;
-    }
-
-    /**
-     * Sets the maximum number of profiles allowed for the tenant.
-     * @param maxProfiles the maximum number of profiles to set (must be >= 0)
-     */
-    public void setMaxProfiles(long maxProfiles) {
-        this.maxProfiles = maxProfiles;
-    }
-
-    /**
-     * Gets the maximum number of events allowed for the tenant per time 
period.
-     * @return the maximum number of events
-     */
-    public long getMaxEvents() {
-        return maxEvents;
-    }
-
-    /**
-     * Sets the maximum number of events allowed for the tenant per time 
period.
-     * @param maxEvents the maximum number of events to set (must be >= 0)
-     */
-    public void setMaxEvents(long maxEvents) {
-        this.maxEvents = maxEvents;
-    }
-
-    /**
-     * Gets the maximum number of rules allowed for the tenant.
-     * @return the maximum number of rules
-     */
-    public long getMaxRules() {
-        return maxRules;
-    }
-
-    /**
-     * Sets the maximum number of rules allowed for the tenant.
-     * @param maxRules the maximum number of rules to set (must be >= 0)
-     */
-    public void setMaxRules(long maxRules) {
-        this.maxRules = maxRules;
-    }
-
-    /**
-     * Gets the maximum number of segments allowed for the tenant.
-     * @return the maximum number of segments
-     */
-    public long getMaxSegments() {
-        return maxSegments;
-    }
-
-    /**
-     * Sets the maximum number of segments allowed for the tenant.
-     * @param maxSegments the maximum number of segments to set (must be >= 0)
-     */
-    public void setMaxSegments(long maxSegments) {
-        this.maxSegments = maxSegments;
-    }
-
-    /**
-     * Gets the maximum storage size in bytes allowed for the tenant.
-     * @return the maximum storage size in bytes
-     */
-    public long getMaxStorageSize() {
-        return maxStorageSize;
-    }
-
-    /**
-     * Sets the maximum storage size in bytes allowed for the tenant.
-     * @param maxStorageSize the maximum storage size in bytes to set (must be 
>= 0)
-     */
-    public void setMaxStorageSize(long maxStorageSize) {
-        this.maxStorageSize = maxStorageSize;
-    }
-
-    /**
-     * Gets the maximum number of concurrent requests allowed for the tenant.
-     * @return the maximum number of concurrent requests
-     */
-    public int getMaxConcurrentRequests() {
-        return maxConcurrentRequests;
-    }
-
-    /**
-     * Sets the maximum number of concurrent requests allowed for the tenant.
-     * @param maxConcurrentRequests the maximum number of concurrent requests 
to set (must be >= 0)
-     */
-    public void setMaxConcurrentRequests(int maxConcurrentRequests) {
-        this.maxConcurrentRequests = maxConcurrentRequests;
-    }
-
-    /**
-     * Gets the maximum number of API keys allowed for the tenant.
-     * @return the maximum number of API keys
-     */
-    public int getMaxApiKeys() {
-        return maxApiKeys;
-    }
-
-    /**
-     * Sets the maximum number of API keys allowed for the tenant.
-     * @param maxApiKeys the maximum number of API keys to set (must be >= 0)
-     */
-    public void setMaxApiKeys(int maxApiKeys) {
-        this.maxApiKeys = maxApiKeys;
-    }
-
-    /**
-     * Gets the maximum number of days to retain data for the tenant.
-     * @return the maximum data retention period in days (0 for no limit)
-     */
-    public long getMaxDataRetentionDays() {
-        return maxDataRetentionDays;
-    }
-
-    /**
-     * Sets the maximum number of days to retain data for the tenant.
-     * @param maxDataRetentionDays the maximum data retention period in days 
to set (0 for no limit, must be >= 0)
-     */
-    public void setMaxDataRetentionDays(long maxDataRetentionDays) {
-        this.maxDataRetentionDays = maxDataRetentionDays;
-    }
-
-    /**
-     * Gets the maximum number of API requests allowed per time period.
-     * @return the maximum number of requests per time period
-     */
-    public long getMaxRequests() {
-        return maxRequests;
-    }
-
-    /**
-     * Sets the maximum number of API requests allowed per time period.
-     * @param maxRequests the maximum number of requests to set (must be >= 0)
-     */
-    public void setMaxRequests(long maxRequests) {
-        this.maxRequests = maxRequests;
-    }
-
-    /**
-     * Gets the custom quotas map. Custom quotas can be used to define
-     * tenant-specific resource limits beyond the standard quotas.
-     * @return map of custom quota types to their limits
-     */
-    public Map<String, Long> getCustomQuotas() {
-        return customQuotas;
-    }
-
-    /**
-     * Sets the custom quotas map. Custom quotas can be used to define
-     * tenant-specific resource limits beyond the standard quotas.
-     * @param customQuotas map of custom quota types to their limits (values 
must be >= 0)
-     */
-    public void setCustomQuotas(Map<String, Long> customQuotas) {
-        this.customQuotas = customQuotas;
-    }
-}
diff --git a/api/src/main/java/org/apache/unomi/api/tenants/Tenant.java 
b/api/src/main/java/org/apache/unomi/api/tenants/Tenant.java
index a4d1dfc5c..6919756fc 100644
--- a/api/src/main/java/org/apache/unomi/api/tenants/Tenant.java
+++ b/api/src/main/java/org/apache/unomi/api/tenants/Tenant.java
@@ -20,6 +20,7 @@ import org.apache.unomi.api.Item;
 
 import java.util.*;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 import javax.xml.bind.annotation.XmlTransient;
 
@@ -62,11 +63,6 @@ public class Tenant extends Item {
      */
     private Date lastModificationDate;
 
-    /**
-     * The resource quota limits for the tenant.
-     * This includes limits on profiles, events, and requests.
-     */
-    private ResourceQuota resourceQuota;
 
     /**
      * The list of all API keys (both active and historical) associated with 
the tenant.
@@ -185,22 +181,6 @@ public class Tenant extends Item {
         this.lastModificationDate = lastModificationDate;
     }
 
-    /**
-     * Gets the tenant's resource quota settings.
-     * @return the resource quota settings
-     */
-    public ResourceQuota getResourceQuota() {
-        return resourceQuota;
-    }
-
-    /**
-     * Sets the tenant's resource quota settings.
-     * @param resourceQuota the resource quota settings to set
-     */
-    public void setResourceQuota(ResourceQuota resourceQuota) {
-        this.resourceQuota = resourceQuota;
-    }
-
     /**
      * Gets the list of all API keys associated with the tenant.
      * This includes both active and historical keys for auditing purposes.
@@ -278,17 +258,7 @@ public class Tenant extends Item {
      */
     @XmlTransient
     public String getPrivateApiKey() {
-        if (apiKeys == null) {
-            return null;
-        }
-        
-        return apiKeys.stream()
-            .filter(key -> key.getKeyType() == ApiKey.ApiKeyType.PRIVATE)
-            .filter(key -> !key.isRevoked())
-            .filter(key -> key.getExpirationDate() == null || 
key.getExpirationDate().after(new Date()))
-            .max(Comparator.comparing(ApiKey::getCreationDate))
-            .map(ApiKey::getMaskedKey)
-            .orElse(null);
+        return getLatestMaskedActiveKey(ApiKey.ApiKeyType.PRIVATE);
     }
 
     /**
@@ -301,17 +271,7 @@ public class Tenant extends Item {
      */
     @XmlTransient
     public String getPublicApiKey() {
-        if (apiKeys == null) {
-            return null;
-        }
-        
-        return apiKeys.stream()
-            .filter(key -> key.getKeyType() == ApiKey.ApiKeyType.PUBLIC)
-            .filter(key -> !key.isRevoked())
-            .filter(key -> key.getExpirationDate() == null || 
key.getExpirationDate().after(new Date()))
-            .max(Comparator.comparing(ApiKey::getCreationDate))
-            .map(ApiKey::getMaskedKey)
-            .orElse(null);
+        return getLatestMaskedActiveKey(ApiKey.ApiKeyType.PUBLIC);
     }
 
     /**
@@ -321,15 +281,7 @@ public class Tenant extends Item {
      */
     @XmlTransient
     public List<ApiKey> getActivePrivateApiKeys() {
-        if (apiKeys == null) {
-            return new ArrayList<>();
-        }
-        
-        return apiKeys.stream()
-            .filter(key -> key.getKeyType() == ApiKey.ApiKeyType.PRIVATE)
-            .filter(key -> !key.isRevoked())
-            .filter(key -> key.getExpirationDate() == null || 
key.getExpirationDate().after(new Date()))
-            .collect(Collectors.toList());
+        return 
streamActiveApiKeysOfType(ApiKey.ApiKeyType.PRIVATE).collect(Collectors.toList());
     }
 
     /**
@@ -339,15 +291,7 @@ public class Tenant extends Item {
      */
     @XmlTransient
     public List<ApiKey> getActivePublicApiKeys() {
-        if (apiKeys == null) {
-            return new ArrayList<>();
-        }
-        
-        return apiKeys.stream()
-            .filter(key -> key.getKeyType() == ApiKey.ApiKeyType.PUBLIC)
-            .filter(key -> !key.isRevoked())
-            .filter(key -> key.getExpirationDate() == null || 
key.getExpirationDate().after(new Date()))
-            .collect(Collectors.toList());
+        return 
streamActiveApiKeysOfType(ApiKey.ApiKeyType.PUBLIC).collect(Collectors.toList());
     }
 
     /**
@@ -360,10 +304,30 @@ public class Tenant extends Item {
         if (apiKeys == null) {
             return new ArrayList<>();
         }
-        
+
         return apiKeys.stream()
-            .filter(key -> !key.isRevoked())
-            .filter(key -> key.getExpirationDate() == null || 
key.getExpirationDate().after(new Date()))
+            .filter(this::isActiveApiKey)
             .collect(Collectors.toList());
     }
+
+    private String getLatestMaskedActiveKey(ApiKey.ApiKeyType keyType) {
+        return streamActiveApiKeysOfType(keyType)
+            .max(Comparator.comparing(ApiKey::getCreationDate))
+            .map(ApiKey::getMaskedKey)
+            .orElse(null);
+    }
+
+    private Stream<ApiKey> streamActiveApiKeysOfType(ApiKey.ApiKeyType 
keyType) {
+        if (apiKeys == null) {
+            return Stream.empty();
+        }
+        return apiKeys.stream()
+            .filter(key -> key.getKeyType() == keyType)
+            .filter(this::isActiveApiKey);
+    }
+
+    private boolean isActiveApiKey(ApiKey key) {
+        return !key.isRevoked()
+                && (key.getExpirationDate() == null || 
key.getExpirationDate().after(new Date()));
+    }
 }
diff --git a/api/src/main/java/org/apache/unomi/api/tenants/TenantUsage.java 
b/api/src/main/java/org/apache/unomi/api/tenants/TenantUsage.java
new file mode 100644
index 000000000..d76b79433
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantUsage.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.api.tenants;
+
+/**
+ * Read-only usage snapshot for a tenant. Values are refreshed on a background 
schedule
+ * (see {@link TenantUsageService}) and may be stale until the next collection 
cycle.
+ */
+public class TenantUsage {
+
+    private String tenantId;
+    private String period;
+    private long profileCount;
+    private long eventCount;
+    private long segmentCount;
+    private long ruleCount;
+    /** Document count across tenant indices (not byte size). */
+    private long storageDocumentCount;
+    /** In-memory REST request counter for this tenant since the Unomi process 
started. */
+    private long restRequestCount;
+    private long collectedAt;
+
+    public String getTenantId() {
+        return tenantId;
+    }
+
+    public void setTenantId(String tenantId) {
+        this.tenantId = tenantId;
+    }
+
+    public String getPeriod() {
+        return period;
+    }
+
+    public void setPeriod(String period) {
+        this.period = period;
+    }
+
+    public long getProfileCount() {
+        return profileCount;
+    }
+
+    public void setProfileCount(long profileCount) {
+        this.profileCount = profileCount;
+    }
+
+    public long getEventCount() {
+        return eventCount;
+    }
+
+    public void setEventCount(long eventCount) {
+        this.eventCount = eventCount;
+    }
+
+    public long getSegmentCount() {
+        return segmentCount;
+    }
+
+    public void setSegmentCount(long segmentCount) {
+        this.segmentCount = segmentCount;
+    }
+
+    public long getRuleCount() {
+        return ruleCount;
+    }
+
+    public void setRuleCount(long ruleCount) {
+        this.ruleCount = ruleCount;
+    }
+
+    public long getStorageDocumentCount() {
+        return storageDocumentCount;
+    }
+
+    public void setStorageDocumentCount(long storageDocumentCount) {
+        this.storageDocumentCount = storageDocumentCount;
+    }
+
+    public long getRestRequestCount() {
+        return restRequestCount;
+    }
+
+    public void setRestRequestCount(long restRequestCount) {
+        this.restRequestCount = restRequestCount;
+    }
+
+    public long getCollectedAt() {
+        return collectedAt;
+    }
+
+    public void setCollectedAt(long collectedAt) {
+        this.collectedAt = collectedAt;
+    }
+}
diff --git 
a/api/src/main/java/org/apache/unomi/api/tenants/TenantUsageService.java 
b/api/src/main/java/org/apache/unomi/api/tenants/TenantUsageService.java
new file mode 100644
index 000000000..8d90122c3
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantUsageService.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.api.tenants;
+
+/**
+ * Provides read-only per-tenant usage metrics for operators and upstream 
control planes.
+ * Unomi does not enforce quotas; callers use these metrics to apply limits 
upstream.
+ */
+public interface TenantUsageService {
+
+    String DEFAULT_PERIOD = "24h";
+
+    /**
+     * Returns cached usage for the tenant, refreshing on demand when no 
snapshot exists yet.
+     *
+     * @param tenantId tenant identifier
+     * @param period reporting window label (currently only {@value 
#DEFAULT_PERIOD} is supported)
+     * @return usage snapshot, or {@code null} if the tenant does not exist
+     */
+    TenantUsage getUsage(String tenantId, String period);
+
+    /**
+     * Records one authenticated REST request for the tenant (in-memory 
counter).
+     */
+    void recordRestRequest(String tenantId);
+}
diff --git a/itests/src/test/java/org/apache/unomi/itests/TenantIT.java 
b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java
index 9c6c6803c..240c19afe 100644
--- a/itests/src/test/java/org/apache/unomi/itests/TenantIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java
@@ -32,7 +32,6 @@ import org.apache.unomi.api.Profile;
 import org.apache.unomi.api.query.Query;
 import org.apache.unomi.api.tenants.ApiKey;
 import org.apache.unomi.api.tenants.ApiKeyCreationResult;
-import org.apache.unomi.api.tenants.ResourceQuota;
 import org.apache.unomi.api.tenants.Tenant;
 import org.junit.Assert;
 import org.junit.Before;
@@ -103,10 +102,7 @@ public class TenantIT extends BaseIT {
 
             // Test update tenant
             retrievedTenant.setName("Updated Rest Test Tenant");
-            ResourceQuota quota = new ResourceQuota();
-            quota.setMaxProfiles(1000L);
-            quota.setMaxEvents(5000L);
-            retrievedTenant.setResourceQuota(quota);
+            retrievedTenant.setDescription("Updated REST test description");
 
             HttpPut updateRequest = new HttpPut(getFullUrl(REST_ENDPOINT + "/" 
+ retrievedTenant.getItemId()));
             updateRequest.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(retrievedTenant), 
ContentType.APPLICATION_JSON));
@@ -119,7 +115,7 @@ public class TenantIT extends BaseIT {
             }
 
             Assert.assertEquals("Tenant name should be updated", "Updated Rest 
Test Tenant", updatedTenant.getName());
-            Assert.assertEquals("Tenant quota should be updated", (Long) 
1000L, (Long) updatedTenant.getResourceQuota().getMaxProfiles());
+            Assert.assertEquals("Tenant description should be updated", 
"Updated REST test description", updatedTenant.getDescription());
 
             // Test generate new API key
             String generateKeyUrl = 
String.format("%s/%s/apikeys?type=%s&validityDays=30",
@@ -624,4 +620,28 @@ public class TenantIT extends BaseIT {
                 200, response.getStatusLine().getStatusCode());
         }
     }
-}
+
+    @Test
+    public void testTenantUsageEndpoint() throws Exception {
+        Tenant tenant = tenantService.createTenant("usage-test-tenant", 
Collections.emptyMap());
+        try {
+            String usageUrl = getFullUrl(REST_ENDPOINT + "/" + 
tenant.getItemId() + "/usage");
+            String usageResponse;
+            try (CloseableHttpResponse response = executeHttpRequest(new 
HttpGet(usageUrl), AuthType.JAAS_ADMIN)) {
+                Assert.assertEquals("Usage endpoint should return 200", 200, 
response.getStatusLine().getStatusCode());
+                usageResponse = EntityUtils.toString(response.getEntity());
+            }
+            Map<?, ?> usage = getObjectMapper().readValue(usageResponse, 
Map.class);
+            Assert.assertEquals("Usage tenantId should match", 
tenant.getItemId(), usage.get("tenantId"));
+            Assert.assertEquals("Default period should be 24h", "24h", 
usage.get("period"));
+            Assert.assertNotNull("collectedAt should be present", 
usage.get("collectedAt"));
+
+            String badPeriodUrl = usageUrl + "?period=7d";
+            try (CloseableHttpResponse response = executeHttpRequest(new 
HttpGet(badPeriodUrl), AuthType.JAAS_ADMIN)) {
+                Assert.assertEquals("Unsupported period should return 400", 
400, response.getStatusLine().getStatusCode());
+            }
+        } finally {
+            tenantService.deleteTenant(tenant.getItemId());
+        }
+    }
+
diff --git a/manual/src/main/asciidoc/multitenancy.adoc 
b/manual/src/main/asciidoc/multitenancy.adoc
index 2d98b1157..41e57f98b 100644
--- a/manual/src/main/asciidoc/multitenancy.adoc
+++ b/manual/src/main/asciidoc/multitenancy.adoc
@@ -28,7 +28,7 @@ Apache Unomi provides robust multi-tenancy support, allowing 
multiple organizati
 * Complete data isolation between tenants
 * Dual API key system (public and private keys)
 * Tenant-specific configuration
-* Resource quotas and limits
+* Per-tenant usage metrics (quotas enforced upstream)
 * Migration tools for existing data
 * Support for both REST and GraphQL APIs
 
@@ -433,15 +433,17 @@ For complete details on the GraphQL multi-tenancy 
implementation, refer to the <
 
 === Monitoring API Usage
 
-Track tenant API usage:
+Unomi exposes read-only usage metrics per tenant. Quota enforcement belongs in 
your upstream gateway or control plane.
 
 [source,bash]
 ----
-curl -X GET http://localhost:8181/cxs/tenants/my-tenant/apiCalls \
+curl -X GET "http://localhost:8181/cxs/tenants/my-tenant/usage?period=24h"; \
   --user karaf:karaf \
   -H "Content-Type: application/json"
 ----
 
+The response includes profile, event, segment, and rule counts, storage 
document count, in-memory REST request count since process start, and 
`collectedAt` (epoch millis). Values refresh on a background schedule and may 
be stale until the next collection cycle.
+
 === Data Migration
 
 Migrate data between tenants:
diff --git a/rest/pom.xml b/rest/pom.xml
index 5380ef5b0..d57a09b24 100644
--- a/rest/pom.xml
+++ b/rest/pom.xml
@@ -200,6 +200,16 @@
             <artifactId>junit-jupiter</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-junit-jupiter</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java
 
b/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java
index 801dce2bb..6f6477404 100644
--- 
a/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java
+++ 
b/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java
@@ -70,6 +70,7 @@ public class DefaultRestAuthenticationConfig implements 
RestAuthenticationConfig
         roles.put("org.apache.unomi.rest.tenants.TenantEndpoint.deleteTenant", 
ADMIN_ROLES);
         
roles.put("org.apache.unomi.rest.tenants.TenantEndpoint.generateApiKey", 
ADMIN_ROLES);
         
roles.put("org.apache.unomi.rest.tenants.TenantEndpoint.validateApiKey", 
ADMIN_ROLES);
+        
roles.put("org.apache.unomi.rest.tenants.TenantEndpoint.getTenantUsage", 
TENANT_ADMIN_ROLES);
         ROLES_MAPPING = Collections.unmodifiableMap(roles);
     }
 
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/security/SecurityFilter.java 
b/rest/src/main/java/org/apache/unomi/rest/security/SecurityFilter.java
index c2b0445aa..b1836d531 100644
--- a/rest/src/main/java/org/apache/unomi/rest/security/SecurityFilter.java
+++ b/rest/src/main/java/org/apache/unomi/rest/security/SecurityFilter.java
@@ -17,6 +17,7 @@
 package org.apache.unomi.rest.security;
 
 import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.tenants.TenantUsageService;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Reference;
 import org.slf4j.Logger;
@@ -32,6 +33,7 @@ import javax.ws.rs.core.Response;
 import javax.ws.rs.core.UriInfo;
 import javax.ws.rs.ext.Provider;
 import java.io.IOException;
+import java.lang.annotation.Annotation;
 import java.lang.reflect.Method;
 
 @Provider
@@ -42,11 +44,14 @@ public class SecurityFilter implements 
ContainerRequestFilter {
     private static final Logger logger = 
LoggerFactory.getLogger(SecurityFilter.class);
 
     /** Name of the {@code @PathParam} that identifies the tenant a {@link 
RequiresTenant} endpoint operates on. */
-    private static final String TENANT_PATH_PARAM = "tenantId";
+    static final String TENANT_PATH_PARAM = "tenantId";
 
     @Reference
     private SecurityService securityService;
 
+    @Reference(cardinality = 
org.osgi.service.component.annotations.ReferenceCardinality.OPTIONAL)
+    private TenantUsageService tenantUsageService;
+
     @Context
     private ResourceInfo resourceInfo;
 
@@ -56,47 +61,23 @@ public class SecurityFilter implements 
ContainerRequestFilter {
     @Override
     public void filter(ContainerRequestContext requestContext) throws 
IOException {
         Method method = resourceInfo.getResourceMethod();
-        RequiresRole roleAnnotation = method.getAnnotation(RequiresRole.class);
-        RequiresTenant tenantAnnotation = 
method.getAnnotation(RequiresTenant.class);
+        RequiresRole roleAnnotation = resolveAnnotation(method, 
RequiresRole.class);
+        RequiresTenant tenantAnnotation = resolveAnnotation(method, 
RequiresTenant.class);
 
         try {
-            // Check role-based access
-            if (roleAnnotation != null) {
-                String[] roles = roleAnnotation.value();
-                boolean hasAccess = false;
-                for (String role : roles) {
-                    if (securityService.hasRole(role)) {
-                        hasAccess = true;
-                        break;
-                    }
-                }
-                if (!hasAccess) {
-                    
requestContext.abortWith(Response.status(Response.Status.FORBIDDEN)
-                            .entity("User does not have required role")
-                            .build());
-                    return;
-                }
+            if (roleAnnotation != null && !hasRequiredRole(roleAnnotation)) {
+                
requestContext.abortWith(Response.status(Response.Status.FORBIDDEN)
+                        .entity("User does not have required role")
+                        .build());
+                return;
             }
 
-            // Check tenants-based access: the tenant being accessed comes 
from the request path
-            // (e.g. /tenants/{tenantId}/...), never from the caller's own 
subject — otherwise the
-            // check would just compare the subject's tenant against itself 
and always pass.
-            if (tenantAnnotation != null) {
-                String requestedTenantId = 
uriInfo.getPathParameters().getFirst(TENANT_PATH_PARAM);
-                if (requestedTenantId == null) {
-                    
requestContext.abortWith(Response.status(Response.Status.BAD_REQUEST)
-                            .entity("Tenant ID is required")
-                            .build());
-                    return;
-                }
-                if (!securityService.hasTenantAccess(requestedTenantId)) {
-                    
requestContext.abortWith(Response.status(Response.Status.FORBIDDEN)
-                            .entity("User does not have access to tenant")
-                            .build());
-                    return;
-                }
+            if (tenantAnnotation != null && 
!hasRequiredTenantAccess(requestContext)) {
+                return;
             }
 
+            recordAuthenticatedRestRequest(tenantAnnotation);
+
         } catch (Exception e) {
             logger.error("Error during security check", e);
             
requestContext.abortWith(Response.status(Response.Status.INTERNAL_SERVER_ERROR)
@@ -104,4 +85,56 @@ public class SecurityFilter implements 
ContainerRequestFilter {
                     .build());
         }
     }
+
+    private boolean hasRequiredRole(RequiresRole roleAnnotation) {
+        for (String role : roleAnnotation.value()) {
+            if (securityService.hasRole(role)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean hasRequiredTenantAccess(ContainerRequestContext 
requestContext) {
+        String requestedTenantId = 
uriInfo.getPathParameters().getFirst(TENANT_PATH_PARAM);
+        if (requestedTenantId == null) {
+            
requestContext.abortWith(Response.status(Response.Status.BAD_REQUEST)
+                    .entity("Tenant ID is required")
+                    .build());
+            return false;
+        }
+        if (!securityService.hasTenantAccess(requestedTenantId)) {
+            requestContext.abortWith(Response.status(Response.Status.FORBIDDEN)
+                    .entity("User does not have access to tenant")
+                    .build());
+            return false;
+        }
+        return true;
+    }
+
+    private void recordAuthenticatedRestRequest(RequiresTenant 
tenantAnnotation) {
+        if (tenantUsageService == null) {
+            return;
+        }
+        String tenantId = null;
+        if (tenantAnnotation != null) {
+            tenantId = uriInfo.getPathParameters().getFirst(TENANT_PATH_PARAM);
+        }
+        if (tenantId == null || tenantId.isEmpty()) {
+            if (!securityService.isOperatingOnSystemTenant()) {
+                tenantId = securityService.getCurrentSubjectTenantId();
+            }
+        }
+        if (tenantId != null && !tenantId.isEmpty()) {
+            tenantUsageService.recordRestRequest(tenantId);
+        }
+    }
+
+    static <A extends Annotation> A resolveAnnotation(Method method, Class<A> 
annotationType) {
+        A annotation = method.getAnnotation(annotationType);
+        if (annotation == null) {
+            annotation = 
method.getDeclaringClass().getAnnotation(annotationType);
+        }
+        return annotation;
+    }
 }
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/tenants/TenantEndpoint.java 
b/rest/src/main/java/org/apache/unomi/rest/tenants/TenantEndpoint.java
index adb1d5693..095a6b13d 100644
--- a/rest/src/main/java/org/apache/unomi/rest/tenants/TenantEndpoint.java
+++ b/rest/src/main/java/org/apache/unomi/rest/tenants/TenantEndpoint.java
@@ -22,7 +22,10 @@ import org.apache.unomi.api.tenants.ApiKey;
 import org.apache.unomi.api.tenants.ApiKeyCreationResult;
 import org.apache.unomi.api.tenants.Tenant;
 import org.apache.unomi.api.tenants.TenantService;
+import org.apache.unomi.api.tenants.TenantUsage;
+import org.apache.unomi.api.tenants.TenantUsageService;
 import org.apache.unomi.rest.security.RequiresRole;
+import org.apache.unomi.rest.security.RequiresTenant;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Reference;
 
@@ -49,6 +52,9 @@ public class TenantEndpoint {
     @Reference
     private TenantService tenantService;
 
+    @Reference
+    private TenantUsageService tenantUsageService;
+
     /**
      * Retrieves all tenants in the system.
      *
@@ -68,6 +74,7 @@ public class TenantEndpoint {
      */
     @GET
     @Path("/{tenantId}")
+    @RequiresTenant
     @Produces(MediaType.APPLICATION_JSON)
     public Response getTenant(@PathParam("tenantId") String tenantId) {
         Tenant tenant = tenantService.getTenant(tenantId);
@@ -107,6 +114,7 @@ public class TenantEndpoint {
      */
     @PUT
     @Path("/{tenantId}")
+    @RequiresTenant
     @Consumes(MediaType.APPLICATION_JSON)
     @Produces(MediaType.APPLICATION_JSON)
     public Tenant updateTenant(@PathParam("tenantId") String tenantId, Tenant 
tenant) {
@@ -131,6 +139,7 @@ public class TenantEndpoint {
      */
     @DELETE
     @Path("/{tenantId}")
+    @RequiresTenant
     public Response deleteTenant(@PathParam("tenantId") String tenantId) {
         if (tenantService.getTenant(tenantId) == null) {
             throw new WebApplicationException("Tenant not found", 
Response.Status.NOT_FOUND);
@@ -151,6 +160,7 @@ public class TenantEndpoint {
      */
     @POST
     @Path("/{tenantId}/apikeys")
+    @RequiresTenant
     @Produces(MediaType.APPLICATION_JSON)
     public ApiKeyCreationResult generateApiKey(@PathParam("tenantId") String 
tenantId,
                                @QueryParam("type") ApiKey.ApiKeyType type,
@@ -180,6 +190,7 @@ public class TenantEndpoint {
      */
     @GET
     @Path("/{tenantId}/apikeys/validate")
+    @RequiresTenant
     public Response validateApiKey(@PathParam("tenantId") String tenantId,
                                  @QueryParam("key") String apiKey,
                                  @QueryParam("type") ApiKey.ApiKeyType type) {
@@ -190,4 +201,30 @@ public class TenantEndpoint {
             return Response.status(Response.Status.UNAUTHORIZED).build();
         }
     }
+
+    /**
+     * Returns a read-only usage snapshot for the tenant. Quota enforcement is 
upstream;
+     * this endpoint exposes measured usage for operators and control planes.
+     *
+     * @param tenantId tenant identifier
+     * @param period reporting window label (currently only {@code 24h} is 
supported)
+     * @return usage snapshot, 404 if tenant is missing, 400 for unsupported 
period
+     */
+    @GET
+    @Path("/{tenantId}/usage")
+    @RequiresTenant
+    @RequiresRole({UnomiRoles.ADMINISTRATOR, UnomiRoles.TENANT_ADMINISTRATOR})
+    @Produces(MediaType.APPLICATION_JSON)
+    public Response getTenantUsage(@PathParam("tenantId") String tenantId,
+                                   @QueryParam("period") 
@DefaultValue(TenantUsageService.DEFAULT_PERIOD) String period) {
+        try {
+            TenantUsage usage = tenantUsageService.getUsage(tenantId, period);
+            if (usage == null) {
+                return Response.status(Response.Status.NOT_FOUND).build();
+            }
+            return Response.ok(usage).build();
+        } catch (IllegalArgumentException e) {
+            throw new WebApplicationException(e.getMessage(), 
Response.Status.BAD_REQUEST);
+        }
+    }
 }
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/security/SecurityFilterTest.java 
b/rest/src/test/java/org/apache/unomi/rest/security/SecurityFilterTest.java
new file mode 100644
index 000000000..afcebc64d
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/security/SecurityFilterTest.java
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.security;
+
+import org.apache.unomi.api.security.SecurityService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.container.ResourceInfo;
+import javax.ws.rs.core.MultivaluedHashMap;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriInfo;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class SecurityFilterTest {
+
+    @Mock
+    private SecurityService securityService;
+
+    @Mock
+    private ResourceInfo resourceInfo;
+
+    @Mock
+    private UriInfo uriInfo;
+
+    @Mock
+    private ContainerRequestContext requestContext;
+
+    private SecurityFilter filter;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        filter = new SecurityFilter();
+        setField(filter, "securityService", securityService);
+        setField(filter, "resourceInfo", resourceInfo);
+        setField(filter, "uriInfo", uriInfo);
+    }
+
+    @Test
+    void requiresTenantChecksPathTenantId() throws Exception {
+        Method method = 
TenantScopedResource.class.getMethod("tenantOperation");
+        when(resourceInfo.getResourceMethod()).thenReturn(method);
+        MultivaluedHashMap<String, String> pathParams = new 
MultivaluedHashMap<>();
+        pathParams.add(SecurityFilter.TENANT_PATH_PARAM, "requested-tenant");
+        when(uriInfo.getPathParameters()).thenReturn(pathParams);
+        
when(securityService.hasTenantAccess("requested-tenant")).thenReturn(false);
+
+        filter.filter(requestContext);
+
+        verify(securityService).hasTenantAccess("requested-tenant");
+        ArgumentCaptor<Response> responseCaptor = 
ArgumentCaptor.forClass(Response.class);
+        verify(requestContext).abortWith(responseCaptor.capture());
+        assertEquals(Response.Status.FORBIDDEN.getStatusCode(), 
responseCaptor.getValue().getStatus());
+    }
+
+    @Test
+    void requiresTenantRejectsMissingPathTenantId() throws Exception {
+        Method method = 
TenantScopedResource.class.getMethod("tenantOperation");
+        when(resourceInfo.getResourceMethod()).thenReturn(method);
+        when(uriInfo.getPathParameters()).thenReturn(new 
MultivaluedHashMap<>());
+
+        filter.filter(requestContext);
+
+        verify(securityService, never()).hasTenantAccess(any());
+        ArgumentCaptor<Response> responseCaptor = 
ArgumentCaptor.forClass(Response.class);
+        verify(requestContext).abortWith(responseCaptor.capture());
+        assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
responseCaptor.getValue().getStatus());
+    }
+
+    @Test
+    void requiresRoleResolvesClassLevelAnnotation() throws Exception {
+        Method method = 
ClassScopedRoleResource.class.getMethod("anyOperation");
+        when(resourceInfo.getResourceMethod()).thenReturn(method);
+        when(securityService.hasRole("ROLE_ADMIN")).thenReturn(true);
+
+        filter.filter(requestContext);
+
+        verify(securityService).hasRole("ROLE_ADMIN");
+        verify(requestContext, never()).abortWith(any());
+    }
+
+    @Test
+    void resolveAnnotationPrefersMethodOverClass() throws Exception {
+        Method method = 
ClassScopedRoleResource.class.getMethod("overriddenOperation");
+        RequiresRole resolved = SecurityFilter.resolveAnnotation(method, 
RequiresRole.class);
+        assertEquals("ROLE_TENANT", resolved.value()[0]);
+    }
+
+    static class TenantScopedResource {
+        @RequiresTenant
+        public void tenantOperation() {
+        }
+    }
+
+    @RequiresRole("ROLE_ADMIN")
+    static class ClassScopedRoleResource {
+        public void anyOperation() {
+        }
+
+        @RequiresRole("ROLE_TENANT")
+        public void overriddenOperation() {
+        }
+    }
+
+    private static void setField(Object target, String fieldName, Object 
value) throws Exception {
+        Field field = target.getClass().getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+}
diff --git 
a/services-common/src/main/java/org/apache/unomi/services/common/security/KarafSecurityService.java
 
b/services-common/src/main/java/org/apache/unomi/services/common/security/KarafSecurityService.java
index f945d90f2..24e79f375 100644
--- 
a/services-common/src/main/java/org/apache/unomi/services/common/security/KarafSecurityService.java
+++ 
b/services-common/src/main/java/org/apache/unomi/services/common/security/KarafSecurityService.java
@@ -168,7 +168,7 @@ public class KarafSecurityService implements 
SecurityService {
 
     @Override
     public void clearCurrentSubject() {
-        currentSubject.remove();
+        clearRequestSubject();
         privilegedSubject.remove();
     }
 
diff --git 
a/services-common/src/test/java/org/apache/unomi/services/common/security/KarafSecurityServiceTest.java
 
b/services-common/src/test/java/org/apache/unomi/services/common/security/KarafSecurityServiceTest.java
index 686b0f03e..6841adf85 100644
--- 
a/services-common/src/test/java/org/apache/unomi/services/common/security/KarafSecurityServiceTest.java
+++ 
b/services-common/src/test/java/org/apache/unomi/services/common/security/KarafSecurityServiceTest.java
@@ -117,6 +117,33 @@ public class KarafSecurityServiceTest {
             
extractRoles(updatedSystemSubject.getPrincipals()).contains(UnomiRoles.SYSTEM_MAINTENANCE));
     }
 
+    @Test
+    public void testRequestSubjectIsIndependentOfPrivilegedSubject() {
+        Subject requestSubject = createTestSubject("requestUser", 
UnomiRoles.USER);
+        Subject privileged = createTestSubject("privUser", 
UnomiRoles.ADMINISTRATOR);
+        securityService.setCurrentSubject(requestSubject);
+        securityService.setPrivilegedSubject(privileged);
+
+        assertEquals("Privileged subject wins for getCurrentSubject()", 
privileged,
+            securityService.getCurrentSubject());
+        assertEquals("Request slot is unchanged", requestSubject, 
securityService.getRequestSubject());
+
+        securityService.clearRequestSubject();
+        assertNull("Request slot cleared", 
securityService.getRequestSubject());
+        assertEquals("Privileged subject still active", privileged, 
securityService.getCurrentSubject());
+    }
+
+    @Test
+    public void testClearCurrentSubjectClearsRequestAndPrivilegedSlots() {
+        securityService.setCurrentSubject(createTestSubject("user", 
UnomiRoles.USER));
+        securityService.setPrivilegedSubject(createTestSubject("admin", 
UnomiRoles.ADMINISTRATOR));
+
+        securityService.clearCurrentSubject();
+
+        assertNull("Request slot cleared", 
securityService.getRequestSubject());
+        assertNull("No effective subject after full clear", 
securityService.getCurrentSubject());
+    }
+
     @Test
     public void testCurrentSubjectManagement() {
         // Test initial state
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantMetrics.java
 
b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantMetrics.java
deleted file mode 100644
index d296fe647..000000000
--- 
a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantMetrics.java
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.unomi.services.impl.tenants;
-
-/**
- * Stores metrics for a tenant including profile count, event count, storage 
size and API calls.
- */
-public class TenantMetrics {
-    private long profileCount;
-    private long eventCount;
-    private long storageSize;
-    private long apiCallCount;
-
-    public long getProfileCount() {
-        return profileCount;
-    }
-
-    public void setProfileCount(long profileCount) {
-        this.profileCount = profileCount;
-    }
-
-    public long getEventCount() {
-        return eventCount;
-    }
-
-    public void setEventCount(long eventCount) {
-        this.eventCount = eventCount;
-    }
-
-    public long getStorageSize() {
-        return storageSize;
-    }
-
-    public void setStorageSize(long storageSize) {
-        this.storageSize = storageSize;
-    }
-
-    public long getApiCallCount() {
-        return apiCallCount;
-    }
-
-    public void setApiCallCount(long apiCallCount) {
-        this.apiCallCount = apiCallCount;
-    }
-}
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantMonitoringService.java
 
b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantMonitoringService.java
deleted file mode 100644
index a491c7952..000000000
--- 
a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantMonitoringService.java
+++ /dev/null
@@ -1,187 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.unomi.services.impl.tenants;
-
-import org.apache.unomi.api.Event;
-import org.apache.unomi.api.Profile;
-import org.apache.unomi.api.conditions.Condition;
-import org.apache.unomi.api.conditions.ConditionType;
-import org.apache.unomi.api.services.DefinitionsService;
-import org.apache.unomi.api.services.ExecutionContextManager;
-import org.apache.unomi.api.tenants.Tenant;
-import org.apache.unomi.api.tenants.TenantService;
-import org.apache.unomi.persistence.spi.PersistenceService;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-
-public class TenantMonitoringService {
-
-    private static final Logger logger = 
LoggerFactory.getLogger(TenantMonitoringService.class);
-
-    private PersistenceService persistenceService;
-    private DefinitionsService definitionsService;
-    private TenantService tenantService;
-    private ExecutionContextManager contextManager;
-
-    private final Map<String, TenantMetrics> metricsCache = new 
ConcurrentHashMap<>();
-    private ScheduledExecutorService executor;
-    private volatile boolean shutdownNow = false;
-
-    public void setPersistenceService(PersistenceService persistenceService) {
-        this.persistenceService = persistenceService;
-    }
-
-    public void setTenantService(TenantService tenantService) {
-        this.tenantService = tenantService;
-    }
-
-    public void setDefinitionsService(DefinitionsService definitionsService) {
-        this.definitionsService = definitionsService;
-    }
-
-    public void setContextManager(ExecutionContextManager contextManager) {
-        this.contextManager = contextManager;
-    }
-
-    public void activate() {
-        shutdownNow = false;
-        startMetricsCollection();
-    }
-
-    public void deactivate() {
-        shutdownNow = true;
-        stopMetricsCollection();
-    }
-
-    public TenantMetrics getMetrics(String tenantId) {
-        return metricsCache.get(tenantId);
-    }
-
-    private void startMetricsCollection() {
-        executor = Executors.newScheduledThreadPool(1, r -> {
-            Thread t = new Thread(r, "Tenant-Metrics-Collector");
-            t.setDaemon(true);
-            return t;
-        });
-        
-        executor.scheduleAtFixedRate(() -> {
-            try {
-                if (shutdownNow) {
-                    return;
-                }
-                
-                if (contextManager == null) {
-                    logger.warn("Context manager not available, skipping 
metrics collection");
-                    return;
-                }
-                
-                contextManager.executeAsSystem(() -> {
-                    try {
-                        if (!shutdownNow && tenantService != null && 
persistenceService != null) {
-                            updateMetrics();
-                        }
-                    } catch (Exception e) {
-                        logger.error("Error updating metrics", e);
-                    }
-                });
-            } catch (Exception e) {
-                logger.error("Error executing metrics update as system 
subject", e);
-            }
-        }, 0, 5, TimeUnit.MINUTES);
-    }
-
-    private void updateMetrics() {
-        if (shutdownNow) {
-            return;
-        }
-        
-        // Check if required condition types are available before updating 
metrics
-        if (definitionsService == null) {
-            logger.debug("DefinitionsService not available, skipping metrics 
update");
-            return;
-        }
-        
-        ConditionType profilePropertyConditionType = 
definitionsService.getConditionType("profilePropertyCondition");
-        ConditionType eventPropertyConditionType = 
definitionsService.getConditionType("eventPropertyCondition");
-        
-        if (profilePropertyConditionType == null || eventPropertyConditionType 
== null) {
-            logger.debug("Required condition types not available 
(profilePropertyCondition: {}, eventPropertyCondition: {}), skipping metrics 
update",
-                    profilePropertyConditionType != null, 
eventPropertyConditionType != null);
-            return;
-        }
-        
-        try {
-            List<Tenant> tenants = tenantService.getAllTenants();
-            for (Tenant tenant : tenants) {
-                if (shutdownNow) return;
-                
-                TenantMetrics metrics = new TenantMetrics();
-                metrics.setProfileCount(countProfiles(tenant.getItemId(), 
profilePropertyConditionType));
-                metrics.setEventCount(countEvents(tenant.getItemId(), 
eventPropertyConditionType));
-                
metrics.setStorageSize(persistenceService.calculateStorageSize(tenant.getItemId()));
-                
metrics.setApiCallCount(persistenceService.getApiCallCount(tenant.getItemId()));
-
-                metricsCache.put(tenant.getItemId(), metrics);
-            }
-        } catch (Exception e) {
-            logger.error("Error updating tenant metrics", e);
-        }
-    }
-
-    private long countProfiles(String tenantId, ConditionType conditionType) {
-        Condition condition = new Condition();
-        condition.setConditionTypeId("profilePropertyCondition");
-        condition.setConditionType(conditionType);
-        condition.setParameter("propertyName", "tenantId");
-        condition.setParameter("comparisonOperator", "equals");
-        condition.setParameter("propertyValue", tenantId);
-        return persistenceService.queryCount(condition, Profile.ITEM_TYPE);
-    }
-
-    private long countEvents(String tenantId, ConditionType conditionType) {
-        Condition condition = new Condition();
-        condition.setConditionTypeId("eventPropertyCondition");
-        condition.setConditionType(conditionType);
-        condition.setParameter("propertyName", "tenantId");
-        condition.setParameter("comparisonOperator", "equals");
-        condition.setParameter("propertyValue", tenantId);
-        return persistenceService.queryCount(condition, Event.ITEM_TYPE);
-    }
-
-    private void stopMetricsCollection() {
-        if (executor != null) {
-            try {
-                executor.shutdownNow();
-                if (!executor.awaitTermination(3, TimeUnit.SECONDS)) {
-                    logger.warn("Executor did not terminate in time, some 
tasks may have been canceled");
-                }
-            } catch (InterruptedException e) {
-                Thread.currentThread().interrupt();
-                logger.warn("Interrupted while shutting down the monitoring 
executor");
-            } finally {
-                executor = null;
-            }
-        }
-    }
-}
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantQuotaService.java
 
b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantQuotaService.java
deleted file mode 100644
index 0b0e7c017..000000000
--- 
a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantQuotaService.java
+++ /dev/null
@@ -1,180 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.unomi.services.impl.tenants;
-
-import org.apache.unomi.api.services.ExecutionContextManager;
-import org.apache.unomi.api.tenants.ResourceQuota;
-import org.apache.unomi.api.tenants.Tenant;
-import org.apache.unomi.api.tenants.TenantService;
-import org.apache.unomi.persistence.spi.PersistenceService;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-
-public class TenantQuotaService {
-
-    private static final Logger logger = 
LoggerFactory.getLogger(TenantQuotaService.class);
-
-    private PersistenceService persistenceService;
-    private TenantService tenantService;
-    private ExecutionContextManager contextManager;
-
-    private Map<String, TenantUsage> usageCache = new ConcurrentHashMap<>();
-    private ScheduledExecutorService executor;
-    private volatile boolean shutdownNow = false;
-
-    public void setPersistenceService(PersistenceService persistenceService) {
-        this.persistenceService = persistenceService;
-    }
-
-    public void setTenantService(TenantService tenantService) {
-        this.tenantService = tenantService;
-    }
-
-    public void setContextManager(ExecutionContextManager contextManager) {
-        this.contextManager = contextManager;
-    }
-
-    public void activate() {
-        shutdownNow = false; // Reset shutdown flag
-        // Start usage monitoring
-        startUsageMonitoring();
-    }
-
-    public void deactivate() {
-        shutdownNow = true; // Set shutdown flag before stopping
-        stopUsageMonitoring();
-    }
-
-    private ResourceQuota getTenantQuota(String tenantId) {
-        Tenant tenant;
-        try {
-            tenant = persistenceService.load(tenantId, Tenant.class);
-        } catch (Exception e) {
-            // Distinguish "failed to load" from "tenant has no quota 
configured" in the logs:
-            // both currently fail open (unlimited) below, but a load failure 
should be visible
-            // to operators instead of silently looking identical to an 
unconfigured quota.
-            logger.error("Failed to load tenant {} while checking quota; 
failing open for this check", tenantId, e);
-            return null;
-        }
-        return tenant != null ? tenant.getResourceQuota() : null;
-    }
-
-    private TenantUsage getUsage(String tenantId) {
-        return usageCache.computeIfAbsent(tenantId, k -> new TenantUsage());
-    }
-
-    public boolean checkQuota(String tenantId, String quotaType, long 
increment) {
-        ResourceQuota quota = getTenantQuota(tenantId);
-        if (quota == null) {
-            return true;
-        }
-        TenantUsage usage = getUsage(tenantId);
-
-        switch (quotaType) {
-            case "profiles":
-                return (usage.getProfileCount() + increment) <= 
quota.getMaxProfiles();
-            case "events":
-                return (usage.getEventCount() + increment) <= 
quota.getMaxEvents();
-            case "storage":
-                return (usage.getStorageSize() + increment) <= 
quota.getMaxStorageSize();
-            default:
-                if (quota.getCustomQuotas().containsKey(quotaType)) {
-                    return (usage.getCustomUsage(quotaType) + increment) <=
-                        quota.getCustomQuotas().get(quotaType);
-                }
-                return true;
-        }
-    }
-
-    private void updateUsageStatistics() {
-        if (shutdownNow || persistenceService == null) {
-            return; // Skip if shutting down or persistence service is 
unavailable
-        }
-        
-        for (String tenantId : usageCache.keySet()) {
-            if (shutdownNow) return; // Check shutdown flag during iteration
-
-            try {
-                TenantUsage usage = usageCache.get(tenantId);
-                
usage.setProfileCount(persistenceService.getAllItemsCount("profile", tenantId));
-                
usage.setEventCount(persistenceService.getAllItemsCount("event", tenantId));
-                // Note: Storage size calculation would require additional 
implementation
-            } catch (Exception e) {
-                // Isolate failures per tenant so one tenant's error (e.g. a 
not-yet-ready index)
-                // doesn't leave every other tenant's usage counts stale for 
this refresh cycle.
-                logger.error("Error updating usage statistics for tenant {}", 
tenantId, e);
-            }
-        }
-    }
-
-    private void startUsageMonitoring() {
-        executor = Executors.newSingleThreadScheduledExecutor(r -> {
-            Thread t = new Thread(r, "Tenant-Usage-Monitor");
-            t.setDaemon(true); // Make it daemon so it doesn't prevent JVM 
shutdown
-            return t;
-        });
-        
-        executor.scheduleAtFixedRate(() -> {
-            try {
-                if (shutdownNow) {
-                    return; // Skip execution if shutting down
-                }
-                
-                if (contextManager == null) {
-                    logger.warn("Context manager not available, skipping usage 
statistics update");
-                    return;
-                }
-                
-                contextManager.executeAsSystem(() -> {
-                    try {
-                        if (!shutdownNow && persistenceService != null) {
-                            updateUsageStatistics();
-                        }
-                    } catch (Exception e) {
-                        logger.error("Error updating usage statistics", e);
-                    }
-                });
-            } catch (Exception e) {
-                logger.error("Error executing usage statistics update as 
system subject", e);
-            }
-        }, 0, 1, TimeUnit.HOURS);
-    }
-
-    private void stopUsageMonitoring() {
-        if (executor != null) {
-            try {
-                // Use shutdownNow instead of shutdown for immediate 
interruption
-                executor.shutdownNow();
-                // Reduce wait time to avoid blocking OSGi shutdown
-                if (!executor.awaitTermination(3, TimeUnit.SECONDS)) {
-                    logger.warn("Executor did not terminate in time, some 
tasks may have been canceled");
-                }
-            } catch (InterruptedException e) {
-                Thread.currentThread().interrupt();
-                logger.warn("Interrupted while shutting down the monitoring 
executor");
-            } finally {
-                executor = null;
-            }
-        }
-    }
-}
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantUsage.java
 
b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantUsage.java
deleted file mode 100644
index bedbf8b8f..000000000
--- 
a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantUsage.java
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.unomi.services.impl.tenants;
-
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-
-public class TenantUsage {
-    private long profileCount;
-    private long eventCount;
-    private long storageSize;
-    private Map<String, Long> customUsage = new ConcurrentHashMap<>();
-
-    public long getProfileCount() {
-        return profileCount;
-    }
-
-    public void setProfileCount(long profileCount) {
-        this.profileCount = profileCount;
-    }
-
-    public long getEventCount() {
-        return eventCount;
-    }
-
-    public void setEventCount(long eventCount) {
-        this.eventCount = eventCount;
-    }
-
-    public long getStorageSize() {
-        return storageSize;
-    }
-
-    public void setStorageSize(long storageSize) {
-        this.storageSize = storageSize;
-    }
-
-    public long getCustomUsage(String type) {
-        return customUsage.getOrDefault(type, 0L);
-    }
-
-    public void setCustomUsage(String type, long value) {
-        customUsage.put(type, value);
-    }
-}
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImpl.java
 
b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImpl.java
new file mode 100644
index 000000000..dd1fc3d40
--- /dev/null
+++ 
b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImpl.java
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.services.impl.tenants;
+
+import org.apache.unomi.api.Event;
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.conditions.ConditionType;
+import org.apache.unomi.api.rules.Rule;
+import org.apache.unomi.api.segments.Segment;
+import org.apache.unomi.api.services.DefinitionsService;
+import org.apache.unomi.api.services.ExecutionContextManager;
+import org.apache.unomi.api.tenants.Tenant;
+import org.apache.unomi.api.tenants.TenantService;
+import org.apache.unomi.api.tenants.TenantUsage;
+import org.apache.unomi.api.tenants.TenantUsageService;
+import org.apache.unomi.persistence.spi.PersistenceService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+public class TenantUsageServiceImpl implements TenantUsageService {
+
+    private static final Logger logger = 
LoggerFactory.getLogger(TenantUsageServiceImpl.class);
+
+    private PersistenceService persistenceService;
+    private DefinitionsService definitionsService;
+    private TenantService tenantService;
+    private ExecutionContextManager contextManager;
+
+    private final Map<String, UsageSnapshot> usageCache = new 
ConcurrentHashMap<>();
+    private final Map<String, AtomicLong> restRequestCounts = new 
ConcurrentHashMap<>();
+    private ScheduledExecutorService executor;
+    private volatile boolean shutdownNow = false;
+
+    public void setPersistenceService(PersistenceService persistenceService) {
+        this.persistenceService = persistenceService;
+    }
+
+    public void setTenantService(TenantService tenantService) {
+        this.tenantService = tenantService;
+    }
+
+    public void setDefinitionsService(DefinitionsService definitionsService) {
+        this.definitionsService = definitionsService;
+    }
+
+    public void setContextManager(ExecutionContextManager contextManager) {
+        this.contextManager = contextManager;
+    }
+
+    public void activate() {
+        shutdownNow = false;
+        startMetricsCollection();
+    }
+
+    public void deactivate() {
+        shutdownNow = true;
+        stopMetricsCollection();
+    }
+
+    @Override
+    public TenantUsage getUsage(String tenantId, String period) {
+        String effectivePeriod = period == null || period.isEmpty() ? 
DEFAULT_PERIOD : period;
+        if (!DEFAULT_PERIOD.equals(effectivePeriod)) {
+            throw new IllegalArgumentException("Unsupported usage period: " + 
effectivePeriod);
+        }
+        if (tenantService.getTenant(tenantId) == null) {
+            return null;
+        }
+        UsageSnapshot snapshot = usageCache.get(tenantId);
+        if (snapshot == null) {
+            refreshTenantUsage(tenantId);
+            snapshot = usageCache.get(tenantId);
+        }
+        if (snapshot == null) {
+            return null;
+        }
+        return toDto(tenantId, effectivePeriod, snapshot);
+    }
+
+    @Override
+    public void recordRestRequest(String tenantId) {
+        if (tenantId != null && !tenantId.isEmpty()) {
+            restRequestCounts.computeIfAbsent(tenantId, id -> new 
AtomicLong()).incrementAndGet();
+        }
+    }
+
+    private void startMetricsCollection() {
+        executor = Executors.newScheduledThreadPool(1, r -> {
+            Thread t = new Thread(r, "Tenant-Usage-Collector");
+            t.setDaemon(true);
+            return t;
+        });
+
+        executor.scheduleAtFixedRate(() -> {
+            try {
+                if (shutdownNow) {
+                    return;
+                }
+
+                if (contextManager == null) {
+                    logger.warn("Context manager not available, skipping usage 
collection");
+                    return;
+                }
+
+                contextManager.executeAsSystem(() -> {
+                    try {
+                        if (!shutdownNow && tenantService != null && 
persistenceService != null) {
+                            refreshAllTenants();
+                        }
+                    } catch (Exception e) {
+                        logger.error("Error updating tenant usage", e);
+                    }
+                });
+            } catch (Exception e) {
+                logger.error("Error executing tenant usage update as system 
subject", e);
+            }
+        }, 0, 5, TimeUnit.MINUTES);
+    }
+
+    private void refreshAllTenants() {
+        if (shutdownNow || definitionsService == null) {
+            return;
+        }
+
+        ConditionType profilePropertyConditionType = 
definitionsService.getConditionType("profilePropertyCondition");
+        ConditionType eventPropertyConditionType = 
definitionsService.getConditionType("eventPropertyCondition");
+
+        if (profilePropertyConditionType == null || eventPropertyConditionType 
== null) {
+            logger.debug("Required condition types not available, skipping 
usage update");
+            return;
+        }
+
+        try {
+            List<Tenant> tenants = tenantService.getAllTenants();
+            for (Tenant tenant : tenants) {
+                if (shutdownNow) {
+                    return;
+                }
+                refreshTenantUsage(tenant.getItemId(), 
profilePropertyConditionType, eventPropertyConditionType);
+            }
+        } catch (Exception e) {
+            logger.error("Error refreshing tenant usage", e);
+        }
+    }
+
+    private void refreshTenantUsage(String tenantId) {
+        if (definitionsService == null) {
+            return;
+        }
+        ConditionType profilePropertyConditionType = 
definitionsService.getConditionType("profilePropertyCondition");
+        ConditionType eventPropertyConditionType = 
definitionsService.getConditionType("eventPropertyCondition");
+        if (profilePropertyConditionType == null || eventPropertyConditionType 
== null) {
+            return;
+        }
+        refreshTenantUsage(tenantId, profilePropertyConditionType, 
eventPropertyConditionType);
+    }
+
+    private void refreshTenantUsage(String tenantId, ConditionType 
profilePropertyConditionType,
+                                    ConditionType eventPropertyConditionType) {
+        if (shutdownNow || persistenceService == null) {
+            return;
+        }
+
+        UsageSnapshot snapshot = new UsageSnapshot();
+        snapshot.profileCount = countByTenantProperty(tenantId, 
profilePropertyConditionType, Profile.ITEM_TYPE);
+        snapshot.eventCount = countByTenantProperty(tenantId, 
eventPropertyConditionType, Event.ITEM_TYPE);
+        snapshot.segmentCount = 
persistenceService.getAllItemsCount(Segment.ITEM_TYPE, tenantId);
+        snapshot.ruleCount = 
persistenceService.getAllItemsCount(Rule.ITEM_TYPE, tenantId);
+        snapshot.storageDocumentCount = 
persistenceService.calculateStorageSize(tenantId);
+        snapshot.collectedAt = System.currentTimeMillis();
+        usageCache.put(tenantId, snapshot);
+    }
+
+    private long countByTenantProperty(String tenantId, ConditionType 
conditionType, String itemType) {
+        Condition condition = new Condition();
+        condition.setConditionTypeId(conditionType.getItemId());
+        condition.setConditionType(conditionType);
+        condition.setParameter("propertyName", "tenantId");
+        condition.setParameter("comparisonOperator", "equals");
+        condition.setParameter("propertyValue", tenantId);
+        return persistenceService.queryCount(condition, itemType);
+    }
+
+    private long currentRestRequestCount(String tenantId) {
+        AtomicLong counter = restRequestCounts.get(tenantId);
+        return counter != null ? counter.get() : 0L;
+    }
+
+    private TenantUsage toDto(String tenantId, String period, UsageSnapshot 
snapshot) {
+        TenantUsage usage = new TenantUsage();
+        usage.setTenantId(tenantId);
+        usage.setPeriod(period);
+        usage.setProfileCount(snapshot.profileCount);
+        usage.setEventCount(snapshot.eventCount);
+        usage.setSegmentCount(snapshot.segmentCount);
+        usage.setRuleCount(snapshot.ruleCount);
+        usage.setStorageDocumentCount(snapshot.storageDocumentCount);
+        usage.setRestRequestCount(currentRestRequestCount(tenantId));
+        usage.setCollectedAt(snapshot.collectedAt);
+        return usage;
+    }
+
+    private void stopMetricsCollection() {
+        if (executor != null) {
+            try {
+                executor.shutdownNow();
+                if (!executor.awaitTermination(3, TimeUnit.SECONDS)) {
+                    logger.warn("Tenant usage executor did not terminate in 
time");
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                logger.warn("Interrupted while shutting down the tenant usage 
executor");
+            } finally {
+                executor = null;
+            }
+        }
+    }
+
+    private static final class UsageSnapshot {
+        private long profileCount;
+        private long eventCount;
+        private long segmentCount;
+        private long ruleCount;
+        private long storageDocumentCount;
+        private long collectedAt;
+    }
+}
diff --git a/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml 
b/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
index 0f93bb1d1..b83b0daf8 100644
--- a/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
+++ b/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
@@ -469,7 +469,7 @@
         <property name="tenantService" ref="tenantServiceImpl"/>
     </bean>
 
-    <bean id="tenantMonitoringServiceImpl" 
class="org.apache.unomi.services.impl.tenants.TenantMonitoringService"
+    <bean id="tenantUsageServiceImpl" 
class="org.apache.unomi.services.impl.tenants.TenantUsageServiceImpl"
           init-method="activate" destroy-method="deactivate">
         <property name="persistenceService" ref="persistenceService"/>
         <property name="definitionsService" ref="definitionsServiceImpl"/>
@@ -477,13 +477,6 @@
         <property name="contextManager" ref="executionContextManager"/>
     </bean>
 
-    <bean id="tenantQuotaServiceImpl" 
class="org.apache.unomi.services.impl.tenants.TenantQuotaService"
-          init-method="activate">
-        <property name="persistenceService" ref="persistenceService"/>
-        <property name="tenantService" ref="tenantServiceImpl"/>
-        <property name="contextManager" ref="executionContextManager"/>
-    </bean>
-
     <!-- Value Type Validator Beans -->
     <bean id="stringValueTypeValidator" 
class="org.apache.unomi.services.impl.validation.validators.StringValueTypeValidator"/>
     <bean id="integerValueTypeValidator" 
class="org.apache.unomi.services.impl.validation.validators.IntegerValueTypeValidator"/>
@@ -670,17 +663,10 @@
         </service-properties>
     </service>
 
-    <service id="tenantMonitoringService" ref="tenantMonitoringServiceImpl"
-             
interface="org.apache.unomi.services.impl.tenants.TenantMonitoringService">
-        <service-properties>
-            <entry key="service.exported.interfaces" 
value="org.apache.unomi.services.impl.tenants.TenantMonitoringService"/>
-        </service-properties>
-    </service>
-
-    <service id="tenantQuotaService" ref="tenantQuotaServiceImpl"
-             
interface="org.apache.unomi.services.impl.tenants.TenantQuotaService">
+    <service id="tenantUsageService" ref="tenantUsageServiceImpl"
+             interface="org.apache.unomi.api.tenants.TenantUsageService">
         <service-properties>
-            <entry key="service.exported.interfaces" 
value="org.apache.unomi.services.impl.tenants.TenantQuotaService"/>
+            <entry key="service.exported.interfaces" 
value="org.apache.unomi.api.tenants.TenantUsageService"/>
         </service-properties>
     </service>
 
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantQuotaServiceTest.java
 
b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantQuotaServiceTest.java
deleted file mode 100644
index 5125f1e38..000000000
--- 
a/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantQuotaServiceTest.java
+++ /dev/null
@@ -1,85 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.unomi.services.impl.tenants;
-
-import org.apache.unomi.persistence.spi.PersistenceService;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-import java.lang.reflect.Field;
-import java.lang.reflect.Method;
-import java.util.Map;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-@ExtendWith(MockitoExtension.class)
-public class TenantQuotaServiceTest {
-
-    @Mock
-    private PersistenceService persistenceService;
-
-    private TenantQuotaService tenantQuotaService;
-
-    @BeforeEach
-    public void setUp() throws Exception {
-        tenantQuotaService = new TenantQuotaService();
-        tenantQuotaService.setPersistenceService(persistenceService);
-
-        Field usageCacheField = 
TenantQuotaService.class.getDeclaredField("usageCache");
-        usageCacheField.setAccessible(true);
-        @SuppressWarnings("unchecked")
-        Map<String, TenantUsage> usageCache = (Map<String, TenantUsage>) 
usageCacheField.get(tenantQuotaService);
-        usageCache.put("tenant-a", new TenantUsage());
-        usageCache.put("tenant-b", new TenantUsage());
-    }
-
-    @Test
-    public void updateUsageStatisticsUsesPerTenantCounts() throws Exception {
-        when(persistenceService.getAllItemsCount(eq("profile"), 
eq("tenant-a"))).thenReturn(10L);
-        when(persistenceService.getAllItemsCount(eq("event"), 
eq("tenant-a"))).thenReturn(20L);
-        when(persistenceService.getAllItemsCount(eq("profile"), 
eq("tenant-b"))).thenReturn(100L);
-        when(persistenceService.getAllItemsCount(eq("event"), 
eq("tenant-b"))).thenReturn(200L);
-
-        Method updateMethod = 
TenantQuotaService.class.getDeclaredMethod("updateUsageStatistics");
-        updateMethod.setAccessible(true);
-        updateMethod.invoke(tenantQuotaService);
-
-        Field usageCacheField = 
TenantQuotaService.class.getDeclaredField("usageCache");
-        usageCacheField.setAccessible(true);
-        @SuppressWarnings("unchecked")
-        Map<String, TenantUsage> usageCache = (Map<String, TenantUsage>) 
usageCacheField.get(tenantQuotaService);
-
-        assertEquals(10L, usageCache.get("tenant-a").getProfileCount());
-        assertEquals(20L, usageCache.get("tenant-a").getEventCount());
-        assertEquals(100L, usageCache.get("tenant-b").getProfileCount());
-        assertEquals(200L, usageCache.get("tenant-b").getEventCount());
-
-        verify(persistenceService).getAllItemsCount("profile", "tenant-a");
-        verify(persistenceService).getAllItemsCount("event", "tenant-a");
-        verify(persistenceService).getAllItemsCount("profile", "tenant-b");
-        verify(persistenceService).getAllItemsCount("event", "tenant-b");
-        verify(persistenceService, never()).getAllItemsCount("profile");
-        verify(persistenceService, never()).getAllItemsCount("event");
-    }
-}
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImplTest.java
 
b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImplTest.java
new file mode 100644
index 000000000..0de9ed568
--- /dev/null
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImplTest.java
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.services.impl.tenants;
+
+import org.apache.unomi.api.conditions.ConditionType;
+import org.apache.unomi.api.services.DefinitionsService;
+import org.apache.unomi.api.tenants.Tenant;
+import org.apache.unomi.api.tenants.TenantService;
+import org.apache.unomi.api.tenants.TenantUsage;
+import org.apache.unomi.api.tenants.TenantUsageService;
+import org.apache.unomi.persistence.spi.PersistenceService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class TenantUsageServiceImplTest {
+
+    @Mock
+    private PersistenceService persistenceService;
+
+    @Mock
+    private DefinitionsService definitionsService;
+
+    @Mock
+    private TenantService tenantService;
+
+    private TenantUsageServiceImpl tenantUsageService;
+
+    @BeforeEach
+    void setUp() {
+        tenantUsageService = new TenantUsageServiceImpl();
+        tenantUsageService.setPersistenceService(persistenceService);
+        tenantUsageService.setDefinitionsService(definitionsService);
+        tenantUsageService.setTenantService(tenantService);
+    }
+
+    @Test
+    void getUsageReturnsNullForMissingTenant() {
+        when(tenantService.getTenant("missing")).thenReturn(null);
+
+        assertNull(tenantUsageService.getUsage("missing", 
TenantUsageService.DEFAULT_PERIOD));
+    }
+
+    @Test
+    void getUsageRejectsUnsupportedPeriod() {
+        assertThrows(IllegalArgumentException.class,
+                () -> tenantUsageService.getUsage("tenant-a", "7d"));
+    }
+
+    @Test
+    void getUsageRefreshesOnDemandWhenCacheEmpty() {
+        Tenant tenant = new Tenant();
+        tenant.setItemId("tenant-a");
+        when(tenantService.getTenant("tenant-a")).thenReturn(tenant);
+
+        ConditionType profileConditionType = new ConditionType();
+        profileConditionType.setItemId("profilePropertyCondition");
+        ConditionType eventConditionType = new ConditionType();
+        eventConditionType.setItemId("eventPropertyCondition");
+        
when(definitionsService.getConditionType("profilePropertyCondition")).thenReturn(profileConditionType);
+        
when(definitionsService.getConditionType("eventPropertyCondition")).thenReturn(eventConditionType);
+        when(persistenceService.queryCount(any(), 
eq("profile"))).thenReturn(12L);
+        when(persistenceService.queryCount(any(), 
eq("event"))).thenReturn(34L);
+        when(persistenceService.getAllItemsCount(eq("segment"), 
eq("tenant-a"))).thenReturn(3L);
+        when(persistenceService.getAllItemsCount(eq("rule"), 
eq("tenant-a"))).thenReturn(5L);
+        
when(persistenceService.calculateStorageSize("tenant-a")).thenReturn(99L);
+
+        TenantUsage usage = tenantUsageService.getUsage("tenant-a", 
TenantUsageService.DEFAULT_PERIOD);
+
+        assertNotNull(usage);
+        assertEquals("tenant-a", usage.getTenantId());
+        assertEquals(TenantUsageService.DEFAULT_PERIOD, usage.getPeriod());
+        assertEquals(12L, usage.getProfileCount());
+        assertEquals(34L, usage.getEventCount());
+        assertEquals(3L, usage.getSegmentCount());
+        assertEquals(5L, usage.getRuleCount());
+        assertEquals(99L, usage.getStorageDocumentCount());
+    }
+
+    @Test
+    void recordRestRequestIncrementsCounter() {
+        tenantUsageService.recordRestRequest("tenant-a");
+        tenantUsageService.recordRestRequest("tenant-a");
+
+        Tenant tenant = new Tenant();
+        tenant.setItemId("tenant-a");
+        when(tenantService.getTenant("tenant-a")).thenReturn(tenant);
+
+        ConditionType profileConditionType = new ConditionType();
+        profileConditionType.setItemId("profilePropertyCondition");
+        ConditionType eventConditionType = new ConditionType();
+        eventConditionType.setItemId("eventPropertyCondition");
+        
when(definitionsService.getConditionType("profilePropertyCondition")).thenReturn(profileConditionType);
+        
when(definitionsService.getConditionType("eventPropertyCondition")).thenReturn(eventConditionType);
+        when(persistenceService.queryCount(any(), 
eq("profile"))).thenReturn(0L);
+        when(persistenceService.queryCount(any(), eq("event"))).thenReturn(0L);
+        when(persistenceService.getAllItemsCount(eq("segment"), 
eq("tenant-a"))).thenReturn(0L);
+        when(persistenceService.getAllItemsCount(eq("rule"), 
eq("tenant-a"))).thenReturn(0L);
+        
when(persistenceService.calculateStorageSize("tenant-a")).thenReturn(0L);
+
+        TenantUsage usage = tenantUsageService.getUsage("tenant-a", 
TenantUsageService.DEFAULT_PERIOD);
+
+        assertEquals(2L, usage.getRestRequestCount());
+    }
+
+    @Test
+    void refreshTenantUsageUsesTenantScopedCounts() throws Exception {
+        ConditionType profileConditionType = new ConditionType();
+        profileConditionType.setItemId("profilePropertyCondition");
+        ConditionType eventConditionType = new ConditionType();
+        eventConditionType.setItemId("eventPropertyCondition");
+
+        when(persistenceService.queryCount(any(), 
eq("profile"))).thenReturn(10L);
+        when(persistenceService.queryCount(any(), 
eq("event"))).thenReturn(20L);
+        when(persistenceService.getAllItemsCount(eq("segment"), 
eq("tenant-a"))).thenReturn(1L);
+        when(persistenceService.getAllItemsCount(eq("rule"), 
eq("tenant-a"))).thenReturn(2L);
+        
when(persistenceService.calculateStorageSize("tenant-a")).thenReturn(50L);
+
+        Method refreshMethod = TenantUsageServiceImpl.class.getDeclaredMethod(
+                "refreshTenantUsage", String.class, ConditionType.class, 
ConditionType.class);
+        refreshMethod.setAccessible(true);
+        refreshMethod.invoke(tenantUsageService, "tenant-a", 
profileConditionType, eventConditionType);
+
+        Tenant tenant = new Tenant();
+        tenant.setItemId("tenant-a");
+        when(tenantService.getTenant("tenant-a")).thenReturn(tenant);
+
+        TenantUsage usage = tenantUsageService.getUsage("tenant-a", 
TenantUsageService.DEFAULT_PERIOD);
+
+        assertEquals(10L, usage.getProfileCount());
+        assertEquals(20L, usage.getEventCount());
+        assertEquals(1L, usage.getSegmentCount());
+        assertEquals(2L, usage.getRuleCount());
+        assertEquals(50L, usage.getStorageDocumentCount());
+    }
+}
diff --git 
a/tools/shell-dev-commands/src/main/java/org/apache/unomi/shell/dev/commands/tenants/TenantCrudCommand.java
 
b/tools/shell-dev-commands/src/main/java/org/apache/unomi/shell/dev/commands/tenants/TenantCrudCommand.java
index 0ce0e785a..48088d720 100644
--- 
a/tools/shell-dev-commands/src/main/java/org/apache/unomi/shell/dev/commands/tenants/TenantCrudCommand.java
+++ 
b/tools/shell-dev-commands/src/main/java/org/apache/unomi/shell/dev/commands/tenants/TenantCrudCommand.java
@@ -22,7 +22,6 @@ import org.apache.karaf.shell.support.table.ShellTable;
 import java.io.PrintStream;
 import org.apache.unomi.api.PartialList;
 import org.apache.unomi.api.query.Query;
-import org.apache.unomi.api.tenants.ResourceQuota;
 import org.apache.unomi.api.tenants.Tenant;
 import org.apache.unomi.api.tenants.TenantService;
 import org.apache.unomi.api.tenants.TenantStatus;
@@ -47,7 +46,7 @@ public class TenantCrudCommand extends BaseCrudCommand {
     private static final Logger LOGGER = 
LoggerFactory.getLogger(TenantCrudCommand.class.getName());
     private static final ObjectMapper OBJECT_MAPPER = new CustomObjectMapper();
     private static final List<String> PROPERTY_NAMES = List.of(
-        "itemId", "name", "description", "status", "creationDate", 
"lastModificationDate", "resourceQuota", "properties", 
"restrictedEventPermissions", "authorizedIPs"
+        "itemId", "name", "description", "status", "creationDate", 
"lastModificationDate", "properties", "restrictedEventPermissions", 
"authorizedIPs"
     );
 
     @Reference
@@ -198,9 +197,6 @@ public class TenantCrudCommand extends BaseCrudCommand {
             if (properties.containsKey("status")) {
                 tenant.setStatus(Enum.valueOf(TenantStatus.class, (String) 
properties.get("status")));
             }
-            if (properties.containsKey("resourceQuota")) {
-                
tenant.setResourceQuota(OBJECT_MAPPER.convertValue(properties.get("resourceQuota"),
 ResourceQuota.class));
-            }
             if (properties.containsKey("properties")) {
                 @SuppressWarnings("unchecked")
                 Map<String, Object> props = (Map<String, Object>) 
properties.get("properties");
@@ -248,7 +244,6 @@ public class TenantCrudCommand extends BaseCrudCommand {
             "Optional properties:",
             "- description: A description of the tenant's purpose or usage",
             "- status: The tenant's status (ACTIVE, DISABLED, etc.)",
-            "- resourceQuota: Resource quota limits for the tenant (profiles, 
events, requests)",
             "- properties: Additional custom properties for the tenant",
             "- restrictedEventPermissions: List of event types that require 
special permissions",
             "- authorizedIPs: List of IP addresses or CIDR ranges authorized 
to make requests"


Reply via email to