From dc0c9058ebcb58d3e6b2e00d32e260f2cd023fdf Mon Sep 17 00:00:00 2001
From: Simon Gurcke <simon@gurcke.de>
Date: Fri, 23 Jan 2026 17:18:19 +1000
Subject: [PATCH] Capture CPU and memory metrics

---
 pom.xml                                       |  5 +++
 .../io/apitally/common/ApitallyClient.java    |  5 ++-
 .../io/apitally/common/ResourceMonitor.java   | 34 +++++++++++++++++++
 .../io/apitally/common/dto/ResourceUsage.java | 23 +++++++++++++
 .../java/io/apitally/common/dto/SyncData.java | 12 ++++++-
 .../apitally/common/ResourceMonitorTest.java  | 26 ++++++++++++++
 6 files changed, 103 insertions(+), 2 deletions(-)
 create mode 100644 src/main/java/io/apitally/common/ResourceMonitor.java
 create mode 100644 src/main/java/io/apitally/common/dto/ResourceUsage.java
 create mode 100644 src/test/java/io/apitally/common/ResourceMonitorTest.java

diff --git a/pom.xml b/pom.xml
index 13986c6..ab8acc4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -49,6 +49,11 @@
             <artifactId>spring-retry</artifactId>
             <version>[2.0.0,3.0.0)</version>
         </dependency>
+        <dependency>
+            <groupId>com.github.oshi</groupId>
+            <artifactId>oshi-core</artifactId>
+            <version>6.9.2</version>
+        </dependency>
         <dependency>
             <groupId>com.github.spotbugs</groupId>
             <artifactId>spotbugs-annotations</artifactId>
diff --git a/src/main/java/io/apitally/common/ApitallyClient.java b/src/main/java/io/apitally/common/ApitallyClient.java
index a647811..b664a78 100644
--- a/src/main/java/io/apitally/common/ApitallyClient.java
+++ b/src/main/java/io/apitally/common/ApitallyClient.java
@@ -72,6 +72,7 @@ public enum HubRequestStatus {
     public final ValidationErrorCounter validationErrorCounter;
     public final ServerErrorCounter serverErrorCounter;
     public final ConsumerRegistry consumerRegistry;
+    public final ResourceMonitor resourceMonitor;
 
     private final Queue<SyncData> syncDataQueue = new ConcurrentLinkedQueue<SyncData>();
     private final Random random = new Random();
@@ -87,6 +88,7 @@ public ApitallyClient(String clientId, String env, RequestLoggingConfig requestL
         this.validationErrorCounter = new ValidationErrorCounter();
         this.serverErrorCounter = new ServerErrorCounter();
         this.consumerRegistry = new ConsumerRegistry();
+        this.resourceMonitor = new ResourceMonitor();
     }
 
     public boolean isEnabled() {
@@ -149,7 +151,8 @@ private void sendSyncData() {
                         requestCounter.getAndResetRequests(),
                         validationErrorCounter.getAndResetValidationErrors(),
                         serverErrorCounter.getAndResetServerErrors(),
-                        consumerRegistry.getAndResetConsumers());
+                        consumerRegistry.getAndResetConsumers(),
+                        resourceMonitor.getCpuMemoryUsage());
         syncDataQueue.offer(data);
 
         int i = 0;
diff --git a/src/main/java/io/apitally/common/ResourceMonitor.java b/src/main/java/io/apitally/common/ResourceMonitor.java
new file mode 100644
index 0000000..0cec7a0
--- /dev/null
+++ b/src/main/java/io/apitally/common/ResourceMonitor.java
@@ -0,0 +1,34 @@
+package io.apitally.common;
+
+import io.apitally.common.dto.ResourceUsage;
+import oshi.SystemInfo;
+import oshi.software.os.OSProcess;
+
+public class ResourceMonitor {
+    private final SystemInfo systemInfo = new SystemInfo();
+    private OSProcess previousSnapshot;
+
+    public ResourceUsage getCpuMemoryUsage() {
+        try {
+            int pid = (int) ProcessHandle.current().pid();
+            OSProcess currentProcess = systemInfo.getOperatingSystem().getProcess(pid);
+            if (currentProcess == null) {
+                return null;
+            }
+
+            if (previousSnapshot == null) {
+                previousSnapshot = currentProcess;
+                return null;
+            }
+
+            double cpuPercent =
+                    100.0 * currentProcess.getProcessCpuLoadBetweenTicks(previousSnapshot);
+            long memoryRss = currentProcess.getResidentSetSize();
+            previousSnapshot = currentProcess;
+
+            return new ResourceUsage(cpuPercent, memoryRss);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+}
diff --git a/src/main/java/io/apitally/common/dto/ResourceUsage.java b/src/main/java/io/apitally/common/dto/ResourceUsage.java
new file mode 100644
index 0000000..4efff2a
--- /dev/null
+++ b/src/main/java/io/apitally/common/dto/ResourceUsage.java
@@ -0,0 +1,23 @@
+package io.apitally.common.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class ResourceUsage {
+    private final double cpuPercent;
+    private final long memoryRss;
+
+    public ResourceUsage(double cpuPercent, long memoryRss) {
+        this.cpuPercent = cpuPercent;
+        this.memoryRss = memoryRss;
+    }
+
+    @JsonProperty("cpu_percent")
+    public double getCpuPercent() {
+        return cpuPercent;
+    }
+
+    @JsonProperty("memory_rss")
+    public long getMemoryRss() {
+        return memoryRss;
+    }
+}
diff --git a/src/main/java/io/apitally/common/dto/SyncData.java b/src/main/java/io/apitally/common/dto/SyncData.java
index bc043a7..6234b12 100644
--- a/src/main/java/io/apitally/common/dto/SyncData.java
+++ b/src/main/java/io/apitally/common/dto/SyncData.java
@@ -1,6 +1,7 @@
 package io.apitally.common.dto;
 
 import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import java.util.List;
 import java.util.UUID;
@@ -13,13 +14,15 @@ public class SyncData extends BaseDto {
     private final List<ValidationErrors> validationErrors;
     private final List<ServerErrors> serverErrors;
     private final List<Consumer> consumers;
+    private final ResourceUsage resources;
 
     public SyncData(
             UUID instanceUuid,
             List<Requests> requests,
             List<ValidationErrors> validationErrors,
             List<ServerErrors> serverErrors,
-            List<Consumer> consumers) {
+            List<Consumer> consumers,
+            ResourceUsage resources) {
         this.timestamp = System.currentTimeMillis() / 1000.0;
         this.instanceUuid = instanceUuid;
         this.messageUuid = UUID.randomUUID();
@@ -27,6 +30,7 @@ public SyncData(
         this.validationErrors = validationErrors;
         this.serverErrors = serverErrors;
         this.consumers = consumers;
+        this.resources = resources;
     }
 
     @JsonProperty("timestamp")
@@ -64,6 +68,12 @@ public List<Consumer> getConsumers() {
         return consumers;
     }
 
+    @JsonProperty("resources")
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public ResourceUsage getResources() {
+        return resources;
+    }
+
     @JsonIgnore
     public double getAgeInSeconds() {
         return System.currentTimeMillis() / 1000.0 - timestamp;
diff --git a/src/test/java/io/apitally/common/ResourceMonitorTest.java b/src/test/java/io/apitally/common/ResourceMonitorTest.java
new file mode 100644
index 0000000..1ace0a5
--- /dev/null
+++ b/src/test/java/io/apitally/common/ResourceMonitorTest.java
@@ -0,0 +1,26 @@
+package io.apitally.common;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.apitally.common.dto.ResourceUsage;
+import org.junit.jupiter.api.Test;
+
+class ResourceMonitorTest {
+
+    @Test
+    void testGetCpuMemoryUsage() throws InterruptedException {
+        ResourceMonitor resourceMonitor = new ResourceMonitor();
+
+        ResourceUsage firstResult = resourceMonitor.getCpuMemoryUsage();
+        assertNull(firstResult);
+
+        Thread.sleep(100);
+
+        ResourceUsage secondResult = resourceMonitor.getCpuMemoryUsage();
+        assertNotNull(secondResult);
+        assertTrue(secondResult.getCpuPercent() >= 0);
+        assertTrue(secondResult.getMemoryRss() > 0);
+    }
+}
