diff --git a/judoscale-core/build.gradle.kts b/judoscale-core/build.gradle.kts
index 2ded615..1d488df 100644
--- a/judoscale-core/build.gradle.kts
+++ b/judoscale-core/build.gradle.kts
@@ -13,6 +13,9 @@ java {
}
dependencies {
+ // JSON processing
+ implementation(libs.jackson.databind)
+
// Testing
testImplementation(libs.junit.jupiter)
testImplementation(libs.assertj.core)
diff --git a/judoscale-core/src/main/java/com/judoscale/core/Adapter.java b/judoscale-core/src/main/java/com/judoscale/core/Adapter.java
new file mode 100644
index 0000000..fab766e
--- /dev/null
+++ b/judoscale-core/src/main/java/com/judoscale/core/Adapter.java
@@ -0,0 +1,67 @@
+package com.judoscale.core;
+
+import java.util.Objects;
+
+/**
+ * Represents an adapter that integrates with Judoscale.
+ * Each adapter (e.g., Spring Boot, job queue libraries) provides its name and version
+ * for identification in the metrics report.
+ */
+public final class Adapter {
+
+ private final String name;
+ private final String version;
+
+ /**
+ * Creates an Adapter with the specified name and version.
+ *
+ * @param name the adapter name (e.g., "judoscale-spring-boot", "judoscale-spring-boot-2"), must not be null
+ * @param version the adapter version, must not be null
+ */
+ public Adapter(String name, String version) {
+ if (name == null) {
+ throw new IllegalArgumentException("Adapter name must not be null");
+ }
+ if (version == null) {
+ throw new IllegalArgumentException("Adapter version must not be null");
+ }
+ this.name = name;
+ this.version = version;
+ }
+
+ /**
+ * Returns the adapter name.
+ *
+ * @return the adapter name
+ */
+ public String name() {
+ return name;
+ }
+
+ /**
+ * Returns the adapter version.
+ *
+ * @return the adapter version
+ */
+ public String version() {
+ return version;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Adapter adapter = (Adapter) o;
+ return Objects.equals(name, adapter.name) && Objects.equals(version, adapter.version);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, version);
+ }
+
+ @Override
+ public String toString() {
+ return "Adapter{name='" + name + "', version='" + version + "'}";
+ }
+}
diff --git a/judoscale-core/src/main/java/com/judoscale/core/ConfigBase.java b/judoscale-core/src/main/java/com/judoscale/core/ConfigBase.java
new file mode 100644
index 0000000..2663abc
--- /dev/null
+++ b/judoscale-core/src/main/java/com/judoscale/core/ConfigBase.java
@@ -0,0 +1,119 @@
+package com.judoscale.core;
+
+/**
+ * Base configuration for Judoscale.
+ * Contains all configuration properties and logic shared across frameworks.
+ *
+ *
Framework-specific implementations (e.g., Spring Boot) should extend this
+ * class and add their configuration binding annotations.
+ */
+public class ConfigBase {
+
+ /**
+ * The base URL for the Judoscale API.
+ * Typically set via JUDOSCALE_URL environment variable.
+ */
+ private String apiBaseUrl;
+
+ /**
+ * Alternative property for the API URL (maps to JUDOSCALE_URL env var via relaxed binding).
+ */
+ private String url;
+
+ /**
+ * How often to report metrics, in seconds. Default is 10.
+ */
+ private int reportIntervalSeconds = 10;
+
+ /**
+ * Maximum request body size in bytes before ignoring queue time.
+ * Large requests can skew queue time measurements. Default is 100KB.
+ */
+ private int maxRequestSizeBytes = 100_000;
+
+ /**
+ * Whether to ignore queue time for large requests. Default is true.
+ */
+ private boolean ignoreLargeRequests = true;
+
+ /**
+ * Log level for Judoscale logging. Default is INFO.
+ */
+ private String logLevel = "INFO";
+
+ /**
+ * Whether Judoscale is enabled. Default is true.
+ */
+ private boolean enabled = true;
+
+ /**
+ * Returns the API base URL, preferring explicit apiBaseUrl over url.
+ */
+ public String getApiBaseUrl() {
+ // Prefer explicit apiBaseUrl, fall back to url (which binds to JUDOSCALE_URL)
+ if (apiBaseUrl != null && !apiBaseUrl.trim().isEmpty()) {
+ return apiBaseUrl;
+ }
+ return url;
+ }
+
+ public void setApiBaseUrl(String apiBaseUrl) {
+ this.apiBaseUrl = apiBaseUrl;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public void setUrl(String url) {
+ this.url = url;
+ }
+
+ public int getReportIntervalSeconds() {
+ return reportIntervalSeconds;
+ }
+
+ public void setReportIntervalSeconds(int reportIntervalSeconds) {
+ this.reportIntervalSeconds = reportIntervalSeconds;
+ }
+
+ public int getMaxRequestSizeBytes() {
+ return maxRequestSizeBytes;
+ }
+
+ public void setMaxRequestSizeBytes(int maxRequestSizeBytes) {
+ this.maxRequestSizeBytes = maxRequestSizeBytes;
+ }
+
+ public boolean isIgnoreLargeRequests() {
+ return ignoreLargeRequests;
+ }
+
+ public void setIgnoreLargeRequests(boolean ignoreLargeRequests) {
+ this.ignoreLargeRequests = ignoreLargeRequests;
+ }
+
+ public String getLogLevel() {
+ return logLevel;
+ }
+
+ public void setLogLevel(String logLevel) {
+ this.logLevel = logLevel;
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ /**
+ * Returns true if the API URL is configured and not blank.
+ */
+ public boolean isConfigured() {
+ String configuredUrl = getApiBaseUrl();
+ return configuredUrl != null && !configuredUrl.trim().isEmpty();
+ }
+}
diff --git a/judoscale-core/src/main/java/com/judoscale/core/QueueTimeCalculator.java b/judoscale-core/src/main/java/com/judoscale/core/QueueTimeCalculator.java
new file mode 100644
index 0000000..c9d5915
--- /dev/null
+++ b/judoscale-core/src/main/java/com/judoscale/core/QueueTimeCalculator.java
@@ -0,0 +1,82 @@
+package com.judoscale.core;
+
+import java.time.Instant;
+
+/**
+ * Utility class for calculating request queue time from the X-Request-Start header.
+ * Handles multiple formats: seconds, milliseconds, microseconds, nanoseconds.
+ */
+public final class QueueTimeCalculator {
+
+ // Cutoffs for determining the unit of the X-Request-Start header
+ private static final long MILLISECONDS_CUTOFF = Instant.parse("2000-01-01T00:00:00Z").toEpochMilli();
+ private static final long MICROSECONDS_CUTOFF = MILLISECONDS_CUTOFF * 1000;
+ private static final long NANOSECONDS_CUTOFF = MICROSECONDS_CUTOFF * 1000;
+
+ private QueueTimeCalculator() {
+ // Utility class, no instantiation
+ }
+
+ /**
+ * Calculates the queue time in milliseconds from the X-Request-Start header.
+ *
+ * @param requestStartHeader the X-Request-Start header value
+ * @param now the current instant
+ * @return the queue time in milliseconds, or -1 if the header could not be parsed
+ */
+ public static long calculateQueueTime(String requestStartHeader, Instant now) {
+ try {
+ // Strip any non-numeric characters (e.g., "t=" prefix from NGINX)
+ String cleanValue = requestStartHeader.replaceAll("[^0-9.]", "");
+
+ long startTimeMs;
+
+ // Use long parsing for integer values to avoid precision loss with large timestamps
+ // (nanosecond timestamps can exceed double's precision)
+ if (!cleanValue.contains(".")) {
+ long value = Long.parseLong(cleanValue);
+ startTimeMs = convertToMillis(value);
+ } else {
+ // Fractional values (typically seconds from NGINX)
+ double value = Double.parseDouble(cleanValue);
+ if (value > NANOSECONDS_CUTOFF) {
+ startTimeMs = (long) (value / 1_000_000);
+ } else if (value > MICROSECONDS_CUTOFF) {
+ startTimeMs = (long) (value / 1_000);
+ } else if (value > MILLISECONDS_CUTOFF) {
+ startTimeMs = (long) value;
+ } else {
+ // Seconds with fractional part
+ startTimeMs = (long) (value * 1000);
+ }
+ }
+
+ long queueTimeMs = now.toEpochMilli() - startTimeMs;
+
+ // Safeguard against negative queue times
+ return Math.max(0, queueTimeMs);
+
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+
+ /**
+ * Converts an integer timestamp to milliseconds based on its magnitude.
+ */
+ private static long convertToMillis(long value) {
+ if (value > NANOSECONDS_CUTOFF) {
+ // Nanoseconds (Render)
+ return value / 1_000_000;
+ } else if (value > MICROSECONDS_CUTOFF) {
+ // Microseconds
+ return value / 1_000;
+ } else if (value > MILLISECONDS_CUTOFF) {
+ // Milliseconds (Heroku)
+ return value;
+ } else {
+ // Seconds (integer seconds, rare but possible)
+ return value * 1000;
+ }
+ }
+}
diff --git a/judoscale-core/src/main/java/com/judoscale/core/ReportBuilder.java b/judoscale-core/src/main/java/com/judoscale/core/ReportBuilder.java
new file mode 100644
index 0000000..b4a44a6
--- /dev/null
+++ b/judoscale-core/src/main/java/com/judoscale/core/ReportBuilder.java
@@ -0,0 +1,84 @@
+package com.judoscale.core;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collection;
+import java.util.List;
+import java.util.Properties;
+
+/**
+ * Utility class for building JSON report payloads for the Judoscale API.
+ */
+public final class ReportBuilder {
+
+ private static final ObjectMapper objectMapper = new ObjectMapper();
+
+ private ReportBuilder() {
+ // Utility class, no instantiation
+ }
+
+ /**
+ * Builds the JSON payload for the metrics report.
+ *
+ * @param metrics the metrics to include in the report
+ * @param adapters the adapters to include in the report (supports multiple adapters)
+ * @return the JSON string
+ */
+ public static String buildReportJson(List metrics, Collection adapters) {
+ ObjectNode root = objectMapper.createObjectNode();
+
+ // Build metrics array: each metric is [timestamp, value, identifier, queueName?]
+ ArrayNode metricsArray = objectMapper.createArrayNode();
+ for (Metric m : metrics) {
+ ArrayNode metricArray = objectMapper.createArrayNode();
+ metricArray.add(m.time().getEpochSecond());
+ metricArray.add(m.value());
+ metricArray.add(m.identifier());
+ if (m.queueName() != null) {
+ metricArray.add(m.queueName());
+ }
+ metricsArray.add(metricArray);
+ }
+ root.set("metrics", metricsArray);
+
+ // Build adapters object - each adapter provides its own name and version
+ ObjectNode adaptersNode = objectMapper.createObjectNode();
+ for (Adapter adapter : adapters) {
+ ObjectNode adapterNode = objectMapper.createObjectNode();
+ adapterNode.put("adapter_version", adapter.version());
+ adaptersNode.set(adapter.name(), adapterNode);
+ }
+ root.set("adapters", adaptersNode);
+
+ try {
+ return objectMapper.writeValueAsString(root);
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException("Failed to serialize metrics to JSON", e);
+ }
+ }
+
+ /**
+ * Loads the adapter version from the META-INF/judoscale.properties file.
+ * Falls back to "unknown" if the file cannot be read.
+ *
+ * @param loaderClass the class to use for loading the resource
+ * @return the adapter version
+ */
+ public static String loadAdapterVersion(Class> loaderClass) {
+ try (InputStream is = loaderClass.getResourceAsStream("/META-INF/judoscale.properties")) {
+ if (is != null) {
+ Properties props = new Properties();
+ props.load(is);
+ return props.getProperty("version", "unknown");
+ }
+ } catch (IOException e) {
+ // Fall through to return unknown
+ }
+ return "unknown";
+ }
+}
diff --git a/judoscale-core/src/main/java/com/judoscale/core/Reporter.java b/judoscale-core/src/main/java/com/judoscale/core/Reporter.java
new file mode 100644
index 0000000..e93e884
--- /dev/null
+++ b/judoscale-core/src/main/java/com/judoscale/core/Reporter.java
@@ -0,0 +1,94 @@
+package com.judoscale.core;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Background reporter that sends collected metrics to the Judoscale API.
+ * Runs on a fixed schedule (default: every 10 seconds).
+ *
+ * This class is framework-agnostic; the scheduling mechanism is provided
+ * by the framework-specific starter (e.g., Spring Boot's @Scheduled).
+ */
+public class Reporter {
+
+ private static final Logger logger = Logger.getLogger(Reporter.class.getName());
+
+ private final MetricsStore metricsStore;
+ private final ApiClient apiClient;
+ private final ConfigBase config;
+ private final UtilizationTracker utilizationTracker;
+ private final AtomicBoolean started = new AtomicBoolean(false);
+
+ public Reporter(MetricsStore metricsStore, ApiClient apiClient, ConfigBase config,
+ UtilizationTracker utilizationTracker) {
+ this.metricsStore = metricsStore;
+ this.apiClient = apiClient;
+ this.config = config;
+ this.utilizationTracker = utilizationTracker;
+ }
+
+ /**
+ * Starts the reporter.
+ */
+ public void start() {
+ if (!config.isConfigured()) {
+ logger.info("Set judoscale.api-base-url (JUDOSCALE_URL) to enable metrics reporting");
+ return;
+ }
+
+ if (started.compareAndSet(false, true)) {
+ logger.info("Judoscale reporter starting, will report every ~" +
+ config.getReportIntervalSeconds() + " seconds");
+ }
+ }
+
+ /**
+ * Reports metrics to the API. Called on a schedule.
+ */
+ public void reportMetrics() {
+ if (!started.get() || !config.isConfigured()) {
+ return;
+ }
+
+ try {
+ // Collect utilization metric if tracker has been started
+ if (utilizationTracker.isStarted()) {
+ int utilizationPct = utilizationTracker.utilizationPct();
+ metricsStore.push("up", utilizationPct, Instant.now());
+ logger.fine("Collected utilization: " + utilizationPct + "%");
+ }
+
+ List metrics = metricsStore.flush();
+
+ if (metrics.isEmpty()) {
+ logger.fine("No metrics to report");
+ return;
+ }
+
+ logger.info("Reporting " + metrics.size() + " metrics");
+ apiClient.reportMetrics(metrics);
+
+ } catch (Exception e) {
+ // Log the exception but don't rethrow - we want the scheduled task to continue
+ logger.log(Level.SEVERE, "Reporter error: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Returns whether the reporter has been started.
+ */
+ public boolean isStarted() {
+ return started.get();
+ }
+
+ /**
+ * Stops the reporter.
+ */
+ public void stop() {
+ started.set(false);
+ }
+}
diff --git a/judoscale-core/src/test/java/com/judoscale/core/AdapterTest.java b/judoscale-core/src/test/java/com/judoscale/core/AdapterTest.java
new file mode 100644
index 0000000..1d1082c
--- /dev/null
+++ b/judoscale-core/src/test/java/com/judoscale/core/AdapterTest.java
@@ -0,0 +1,42 @@
+package com.judoscale.core;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class AdapterTest {
+
+ @Test
+ void createsAdapterWithNameAndVersion() {
+ Adapter adapter = new Adapter("judoscale-spring-boot", "1.0.0");
+
+ assertThat(adapter.name()).isEqualTo("judoscale-spring-boot");
+ assertThat(adapter.version()).isEqualTo("1.0.0");
+ }
+
+ @Test
+ void throwsExceptionWhenNameIsNull() {
+ assertThatThrownBy(() -> new Adapter(null, "1.0.0"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Adapter name must not be null");
+ }
+
+ @Test
+ void throwsExceptionWhenVersionIsNull() {
+ assertThatThrownBy(() -> new Adapter("judoscale-spring-boot", null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Adapter version must not be null");
+ }
+
+ @Test
+ void equalsAndHashCodeWorkCorrectly() {
+ Adapter adapter1 = new Adapter("judoscale-spring-boot", "1.0.0");
+ Adapter adapter2 = new Adapter("judoscale-spring-boot", "1.0.0");
+ Adapter adapter3 = new Adapter("judoscale-spring-boot-2", "1.0.0");
+
+ assertThat(adapter1).isEqualTo(adapter2);
+ assertThat(adapter1.hashCode()).isEqualTo(adapter2.hashCode());
+ assertThat(adapter1).isNotEqualTo(adapter3);
+ }
+}
diff --git a/judoscale-core/src/test/java/com/judoscale/core/ConfigBaseTest.java b/judoscale-core/src/test/java/com/judoscale/core/ConfigBaseTest.java
new file mode 100644
index 0000000..a971739
--- /dev/null
+++ b/judoscale-core/src/test/java/com/judoscale/core/ConfigBaseTest.java
@@ -0,0 +1,86 @@
+package com.judoscale.core;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ConfigBaseTest {
+
+ private ConfigBase config;
+
+ @BeforeEach
+ void setUp() {
+ config = new ConfigBase();
+ }
+
+ @Test
+ void defaultValues() {
+ assertThat(config.getReportIntervalSeconds()).isEqualTo(10);
+ assertThat(config.getMaxRequestSizeBytes()).isEqualTo(100_000);
+ assertThat(config.isIgnoreLargeRequests()).isTrue();
+ assertThat(config.getLogLevel()).isEqualTo("INFO");
+ assertThat(config.isEnabled()).isTrue();
+ }
+
+ @Test
+ void isConfiguredReturnsFalseWhenUrlIsNull() {
+ assertThat(config.isConfigured()).isFalse();
+ }
+
+ @Test
+ void isConfiguredReturnsFalseWhenUrlIsBlank() {
+ config.setApiBaseUrl(" ");
+ assertThat(config.isConfigured()).isFalse();
+ }
+
+ @Test
+ void isConfiguredReturnsTrueWhenApiBaseUrlIsSet() {
+ config.setApiBaseUrl("http://example.com/api/test-token");
+ assertThat(config.isConfigured()).isTrue();
+ }
+
+ @Test
+ void isConfiguredReturnsTrueWhenUrlIsSet() {
+ config.setUrl("http://example.com/api/test-token");
+ assertThat(config.isConfigured()).isTrue();
+ }
+
+ @Test
+ void getApiBaseUrlPrefersApiBaseUrlOverUrl() {
+ config.setApiBaseUrl("http://explicit.com");
+ config.setUrl("http://fallback.com");
+
+ assertThat(config.getApiBaseUrl()).isEqualTo("http://explicit.com");
+ }
+
+ @Test
+ void getApiBaseUrlFallsBackToUrlWhenApiBaseUrlIsNull() {
+ config.setUrl("http://fallback.com");
+
+ assertThat(config.getApiBaseUrl()).isEqualTo("http://fallback.com");
+ }
+
+ @Test
+ void getApiBaseUrlFallsBackToUrlWhenApiBaseUrlIsBlank() {
+ config.setApiBaseUrl(" ");
+ config.setUrl("http://fallback.com");
+
+ assertThat(config.getApiBaseUrl()).isEqualTo("http://fallback.com");
+ }
+
+ @Test
+ void settersUpdateValues() {
+ config.setReportIntervalSeconds(30);
+ config.setMaxRequestSizeBytes(50_000);
+ config.setIgnoreLargeRequests(false);
+ config.setLogLevel("DEBUG");
+ config.setEnabled(false);
+
+ assertThat(config.getReportIntervalSeconds()).isEqualTo(30);
+ assertThat(config.getMaxRequestSizeBytes()).isEqualTo(50_000);
+ assertThat(config.isIgnoreLargeRequests()).isFalse();
+ assertThat(config.getLogLevel()).isEqualTo("DEBUG");
+ assertThat(config.isEnabled()).isFalse();
+ }
+}
diff --git a/judoscale-core/src/test/java/com/judoscale/core/QueueTimeCalculatorTest.java b/judoscale-core/src/test/java/com/judoscale/core/QueueTimeCalculatorTest.java
new file mode 100644
index 0000000..b9db02a
--- /dev/null
+++ b/judoscale-core/src/test/java/com/judoscale/core/QueueTimeCalculatorTest.java
@@ -0,0 +1,93 @@
+package com.judoscale.core;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class QueueTimeCalculatorTest {
+
+ @Test
+ void calculateQueueTimeFromMilliseconds() {
+ // Heroku format: milliseconds since epoch
+ Instant now = Instant.parse("2024-01-15T10:30:00.100Z");
+ String header = "1705314600000"; // 2024-01-15T10:30:00Z in ms
+
+ long queueTime = QueueTimeCalculator.calculateQueueTime(header, now);
+
+ assertThat(queueTime).isEqualTo(100);
+ }
+
+ @Test
+ void calculateQueueTimeFromMicroseconds() {
+ Instant now = Instant.parse("2024-01-15T10:30:00.100Z");
+ String header = "1705314600000000"; // microseconds
+
+ long queueTime = QueueTimeCalculator.calculateQueueTime(header, now);
+
+ assertThat(queueTime).isEqualTo(100);
+ }
+
+ @Test
+ void calculateQueueTimeFromNanoseconds() {
+ // Render format: nanoseconds since epoch
+ Instant now = Instant.parse("2024-01-15T10:30:00.100Z");
+ String header = "1705314600000000000"; // nanoseconds
+
+ long queueTime = QueueTimeCalculator.calculateQueueTime(header, now);
+
+ assertThat(queueTime).isEqualTo(100);
+ }
+
+ @Test
+ void calculateQueueTimeFromSecondsWithFraction() {
+ // NGINX format: seconds with fractional part
+ Instant now = Instant.parse("2024-01-15T10:30:00.100Z");
+ String header = "1705314600.000"; // seconds
+
+ long queueTime = QueueTimeCalculator.calculateQueueTime(header, now);
+
+ assertThat(queueTime).isEqualTo(100);
+ }
+
+ @Test
+ void calculateQueueTimeStripsNonNumericPrefix() {
+ // NGINX sometimes prefixes with "t="
+ Instant now = Instant.parse("2024-01-15T10:30:00.100Z");
+ String header = "t=1705314600000";
+
+ long queueTime = QueueTimeCalculator.calculateQueueTime(header, now);
+
+ assertThat(queueTime).isEqualTo(100);
+ }
+
+ @Test
+ void calculateQueueTimeReturnsZeroForNegativeValues() {
+ // If the header timestamp is in the future, return 0
+ Instant now = Instant.parse("2024-01-15T10:30:00Z");
+ String header = "1705314601000"; // 1 second in the future
+
+ long queueTime = QueueTimeCalculator.calculateQueueTime(header, now);
+
+ assertThat(queueTime).isEqualTo(0);
+ }
+
+ @Test
+ void calculateQueueTimeReturnsNegativeOneForInvalidHeader() {
+ Instant now = Instant.now();
+
+ long queueTime = QueueTimeCalculator.calculateQueueTime("invalid", now);
+
+ assertThat(queueTime).isEqualTo(-1);
+ }
+
+ @Test
+ void calculateQueueTimeReturnsNegativeOneForEmptyHeader() {
+ Instant now = Instant.now();
+
+ long queueTime = QueueTimeCalculator.calculateQueueTime("", now);
+
+ assertThat(queueTime).isEqualTo(-1);
+ }
+}
diff --git a/judoscale-core/src/test/java/com/judoscale/core/ReportBuilderTest.java b/judoscale-core/src/test/java/com/judoscale/core/ReportBuilderTest.java
new file mode 100644
index 0000000..a97de1e
--- /dev/null
+++ b/judoscale-core/src/test/java/com/judoscale/core/ReportBuilderTest.java
@@ -0,0 +1,92 @@
+package com.judoscale.core;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ReportBuilderTest {
+
+ private static final Adapter TEST_ADAPTER = new Adapter("judoscale-test", "1.0.0");
+
+ @Test
+ void buildReportJsonFormatsMetricsCorrectly() {
+ Instant time = Instant.parse("2024-01-15T10:30:00Z");
+ List metrics = Arrays.asList(
+ new Metric("qt", 100, time),
+ new Metric("at", 50, time)
+ );
+
+ String json = ReportBuilder.buildReportJson(metrics, Collections.singletonList(TEST_ADAPTER));
+
+ assertThat(json).contains("\"metrics\":");
+ assertThat(json).contains("[1705314600,100,\"qt\"]");
+ assertThat(json).contains("[1705314600,50,\"at\"]");
+ assertThat(json).contains("\"adapters\":");
+ assertThat(json).contains("\"judoscale-test\"");
+ assertThat(json).contains("\"adapter_version\":\"1.0.0\"");
+ }
+
+ @Test
+ void buildReportJsonIncludesQueueNameWhenPresent() {
+ Instant time = Instant.parse("2024-01-15T10:30:00Z");
+ List metrics = Collections.singletonList(
+ new Metric("qd", 5, time, "default")
+ );
+
+ String json = ReportBuilder.buildReportJson(metrics, Collections.singletonList(TEST_ADAPTER));
+
+ assertThat(json).contains("[1705314600,5,\"qd\",\"default\"]");
+ }
+
+ @Test
+ void buildReportJsonHandlesEmptyMetricsList() {
+ String json = ReportBuilder.buildReportJson(Collections.emptyList(), Collections.singletonList(TEST_ADAPTER));
+
+ assertThat(json).contains("\"metrics\":[]");
+ }
+
+ @Test
+ void buildReportJsonEscapesSpecialCharactersInQueueName() {
+ Instant time = Instant.parse("2024-01-15T10:30:00Z");
+ List metrics = Collections.singletonList(
+ new Metric("qd", 5, time, "queue\"with\\special")
+ );
+
+ String json = ReportBuilder.buildReportJson(metrics, Collections.singletonList(TEST_ADAPTER));
+
+ assertThat(json).contains("\"queue\\\"with\\\\special\"");
+ }
+
+ @Test
+ void buildReportJsonSupportsMultipleAdapters() {
+ Adapter springBootAdapter = new Adapter("judoscale-spring-boot", "1.0.0");
+ Adapter sidekiqAdapter = new Adapter("judoscale-sidekiq", "2.0.0");
+ List adapters = Arrays.asList(springBootAdapter, sidekiqAdapter);
+
+ String json = ReportBuilder.buildReportJson(Collections.emptyList(), adapters);
+
+ assertThat(json).contains("\"judoscale-spring-boot\"");
+ assertThat(json).contains("\"judoscale-sidekiq\"");
+ assertThat(json).contains("\"adapter_version\":\"1.0.0\"");
+ assertThat(json).contains("\"adapter_version\":\"2.0.0\"");
+ }
+
+ @Test
+ void buildReportJsonHandlesEmptyAdaptersList() {
+ String json = ReportBuilder.buildReportJson(Collections.emptyList(), Collections.emptyList());
+
+ assertThat(json).contains("\"adapters\":{}");
+ }
+
+ @Test
+ void loadAdapterVersionReturnsUnknownWhenFileNotFound() {
+ String version = ReportBuilder.loadAdapterVersion(ReportBuilderTest.class);
+
+ assertThat(version).isEqualTo("unknown");
+ }
+}
diff --git a/judoscale-core/src/test/java/com/judoscale/core/ReporterTest.java b/judoscale-core/src/test/java/com/judoscale/core/ReporterTest.java
new file mode 100644
index 0000000..ff38cc1
--- /dev/null
+++ b/judoscale-core/src/test/java/com/judoscale/core/ReporterTest.java
@@ -0,0 +1,157 @@
+package com.judoscale.core;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ReporterTest {
+
+ private MetricsStore metricsStore;
+ private UtilizationTracker utilizationTracker;
+ private TestApiClient apiClient;
+ private ConfigBase config;
+ private Reporter reporter;
+
+ @BeforeEach
+ void setUp() {
+ metricsStore = new MetricsStore();
+ utilizationTracker = new UtilizationTracker();
+ apiClient = new TestApiClient();
+ config = new ConfigBase();
+ config.setApiBaseUrl("http://example.com/api/test-token");
+ reporter = new Reporter(metricsStore, apiClient, config, utilizationTracker);
+ }
+
+ @Test
+ void startDoesNothingWhenNotConfigured() {
+ ConfigBase unconfiguredConfig = new ConfigBase();
+ reporter = new Reporter(metricsStore, apiClient, unconfiguredConfig, utilizationTracker);
+
+ reporter.start();
+
+ assertThat(reporter.isStarted()).isFalse();
+ }
+
+ @Test
+ void startMarksReporterAsStarted() {
+ reporter.start();
+
+ assertThat(reporter.isStarted()).isTrue();
+ }
+
+ @Test
+ void startOnlyStartsOnce() {
+ reporter.start();
+ reporter.start();
+ reporter.start();
+
+ assertThat(reporter.isStarted()).isTrue();
+ }
+
+ @Test
+ void reportMetricsDoesNothingWhenNotStarted() {
+ metricsStore.push("qt", 100, Instant.now());
+
+ reporter.reportMetrics();
+
+ assertThat(apiClient.reportedMetricsCount).isEqualTo(0);
+ }
+
+ @Test
+ void reportMetricsDoesNothingWhenNotConfigured() {
+ ConfigBase unconfiguredConfig = new ConfigBase();
+ reporter = new Reporter(metricsStore, apiClient, unconfiguredConfig, utilizationTracker);
+ reporter.start();
+ metricsStore.push("qt", 100, Instant.now());
+
+ reporter.reportMetrics();
+
+ assertThat(apiClient.reportedMetricsCount).isEqualTo(0);
+ }
+
+ @Test
+ void reportMetricsFlushesAndSendsCollectedMetrics() {
+ reporter.start();
+ metricsStore.push("qt", 100, Instant.now());
+ metricsStore.push("at", 50, Instant.now());
+
+ reporter.reportMetrics();
+
+ assertThat(apiClient.reportedMetricsCount).isEqualTo(2);
+ }
+
+ @Test
+ void reportMetricsDoesNothingWhenNoMetrics() {
+ reporter.start();
+
+ reporter.reportMetrics();
+
+ assertThat(apiClient.reportedMetricsCount).isEqualTo(0);
+ }
+
+ @Test
+ void reportMetricsClearsMetricsAfterReporting() {
+ reporter.start();
+ metricsStore.push("qt", 100, Instant.now());
+
+ reporter.reportMetrics();
+
+ assertThat(metricsStore.getMetrics()).isEmpty();
+ }
+
+ @Test
+ void stopMarksReporterAsStopped() {
+ reporter.start();
+ assertThat(reporter.isStarted()).isTrue();
+
+ reporter.stop();
+
+ assertThat(reporter.isStarted()).isFalse();
+ }
+
+ @Test
+ void reportMetricsDoesNothingAfterStop() {
+ reporter.start();
+ metricsStore.push("qt", 100, Instant.now());
+ reporter.stop();
+
+ reporter.reportMetrics();
+
+ assertThat(apiClient.reportedMetricsCount).isEqualTo(0);
+ }
+
+ @Test
+ void reportMetricsCollectsUtilizationWhenTrackerIsStarted() {
+ reporter.start();
+ utilizationTracker.start();
+
+ reporter.reportMetrics();
+
+ assertThat(apiClient.reportedMetricsCount).isEqualTo(1);
+ }
+
+ @Test
+ void reportMetricsDoesNotCollectUtilizationWhenTrackerIsNotStarted() {
+ reporter.start();
+ // Don't start utilizationTracker
+
+ reporter.reportMetrics();
+
+ assertThat(apiClient.reportedMetricsCount).isEqualTo(0);
+ }
+
+ // Test implementations
+
+ private static class TestApiClient implements ApiClient {
+ int reportedMetricsCount = 0;
+
+ @Override
+ public boolean reportMetrics(java.util.List metrics) {
+ reportedMetricsCount = metrics.size();
+ return true;
+ }
+ }
+}
diff --git a/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleApiClient.java b/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleApiClient.java
index 6fc5004..f8ecb39 100644
--- a/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleApiClient.java
+++ b/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleApiClient.java
@@ -1,11 +1,9 @@
package com.judoscale.spring;
+import com.judoscale.core.Adapter;
import com.judoscale.core.ApiClient;
import com.judoscale.core.Metric;
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.node.ArrayNode;
-import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.judoscale.core.ReportBuilder;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
@@ -19,9 +17,8 @@
import java.io.Closeable;
import java.io.IOException;
-import java.io.InputStream;
+import java.util.Collections;
import java.util.List;
-import java.util.Properties;
/**
* HTTP client for sending metrics to the Judoscale API.
@@ -30,9 +27,11 @@
public class JudoscaleApiClient implements ApiClient, Closeable {
private static final Logger logger = LoggerFactory.getLogger(JudoscaleApiClient.class);
- private static final ObjectMapper objectMapper = new ObjectMapper();
private static final int MAX_RETRIES = 3;
- private static final String ADAPTER_VERSION = loadAdapterVersion();
+ private static final Adapter ADAPTER = new Adapter(
+ "judoscale-spring-boot-2",
+ ReportBuilder.loadAdapterVersion(JudoscaleApiClient.class)
+ );
private final JudoscaleConfig config;
private final CloseableHttpClient httpClient;
@@ -57,23 +56,6 @@ public JudoscaleApiClient(JudoscaleConfig config) {
this.httpClient = httpClient;
}
- /**
- * Loads the adapter version from the META-INF/judoscale.properties file.
- * Falls back to "unknown" if the file cannot be read.
- */
- private static String loadAdapterVersion() {
- try (InputStream is = JudoscaleApiClient.class.getResourceAsStream("/META-INF/judoscale.properties")) {
- if (is != null) {
- Properties props = new Properties();
- props.load(is);
- return props.getProperty("version", "unknown");
- }
- } catch (IOException e) {
- logger.debug("Could not load judoscale.properties: {}", e.getMessage());
- }
- return "unknown";
- }
-
@Override
public boolean reportMetrics(List metrics) {
if (!config.isConfigured()) {
@@ -81,7 +63,7 @@ public boolean reportMetrics(List metrics) {
return false;
}
- String json = buildReportJson(metrics);
+ String json = ReportBuilder.buildReportJson(metrics, Collections.singletonList(ADAPTER));
String url = config.getApiBaseUrl() + "/v3/reports";
for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
@@ -126,40 +108,6 @@ public boolean reportMetrics(List metrics) {
return false;
}
- /**
- * Builds the JSON payload for the metrics report.
- */
- String buildReportJson(List metrics) {
- ObjectNode root = objectMapper.createObjectNode();
-
- // Build metrics array: each metric is [timestamp, value, identifier, queueName?]
- ArrayNode metricsArray = objectMapper.createArrayNode();
- for (Metric m : metrics) {
- ArrayNode metricArray = objectMapper.createArrayNode();
- metricArray.add(m.time().getEpochSecond());
- metricArray.add(m.value());
- metricArray.add(m.identifier());
- if (m.queueName() != null) {
- metricArray.add(m.queueName());
- }
- metricsArray.add(metricArray);
- }
- root.set("metrics", metricsArray);
-
- // Build adapters object
- ObjectNode adapters = objectMapper.createObjectNode();
- ObjectNode springBootAdapter = objectMapper.createObjectNode();
- springBootAdapter.put("adapter_version", ADAPTER_VERSION);
- adapters.set("judoscale-spring-boot", springBootAdapter);
- root.set("adapters", adapters);
-
- try {
- return objectMapper.writeValueAsString(root);
- } catch (JsonProcessingException e) {
- throw new RuntimeException("Failed to serialize metrics to JSON", e);
- }
- }
-
/**
* Closes the underlying HTTP client and releases any system resources associated with it.
* This includes connection pools and background threads maintained by Apache HttpClient.
diff --git a/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleConfig.java b/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleConfig.java
index b293956..7391651 100644
--- a/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleConfig.java
+++ b/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleConfig.java
@@ -1,11 +1,12 @@
package com.judoscale.spring;
+import com.judoscale.core.ConfigBase;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Configuration properties for Judoscale.
* Can be set via application.properties/yml or environment variables.
- *
+ *
* The API URL can be configured in several ways (in order of precedence):
*
* - {@code judoscale.api-base-url} property
@@ -14,110 +15,5 @@
*
*/
@ConfigurationProperties(prefix = "judoscale")
-public class JudoscaleConfig {
-
- /**
- * The base URL for the Judoscale API.
- * Typically set via JUDOSCALE_URL environment variable.
- */
- private String apiBaseUrl;
-
- /**
- * Alternative property for the API URL (maps to JUDOSCALE_URL env var via relaxed binding).
- */
- private String url;
-
- /**
- * How often to report metrics, in seconds. Default is 10.
- */
- private int reportIntervalSeconds = 10;
-
- /**
- * Maximum request body size in bytes before ignoring queue time.
- * Large requests can skew queue time measurements. Default is 100KB.
- */
- private int maxRequestSizeBytes = 100_000;
-
- /**
- * Whether to ignore queue time for large requests. Default is true.
- */
- private boolean ignoreLargeRequests = true;
-
- /**
- * Log level for Judoscale logging. Default is INFO.
- */
- private String logLevel = "INFO";
-
- /**
- * Whether Judoscale is enabled. Default is true.
- */
- private boolean enabled = true;
-
- public String getApiBaseUrl() {
- // Prefer explicit apiBaseUrl, fall back to url (which binds to JUDOSCALE_URL)
- if (apiBaseUrl != null && !apiBaseUrl.trim().isEmpty()) {
- return apiBaseUrl;
- }
- return url;
- }
-
- public void setApiBaseUrl(String apiBaseUrl) {
- this.apiBaseUrl = apiBaseUrl;
- }
-
- public String getUrl() {
- return url;
- }
-
- public void setUrl(String url) {
- this.url = url;
- }
-
- public int getReportIntervalSeconds() {
- return reportIntervalSeconds;
- }
-
- public void setReportIntervalSeconds(int reportIntervalSeconds) {
- this.reportIntervalSeconds = reportIntervalSeconds;
- }
-
- public int getMaxRequestSizeBytes() {
- return maxRequestSizeBytes;
- }
-
- public void setMaxRequestSizeBytes(int maxRequestSizeBytes) {
- this.maxRequestSizeBytes = maxRequestSizeBytes;
- }
-
- public boolean isIgnoreLargeRequests() {
- return ignoreLargeRequests;
- }
-
- public void setIgnoreLargeRequests(boolean ignoreLargeRequests) {
- this.ignoreLargeRequests = ignoreLargeRequests;
- }
-
- public String getLogLevel() {
- return logLevel;
- }
-
- public void setLogLevel(String logLevel) {
- this.logLevel = logLevel;
- }
-
- public boolean isEnabled() {
- return enabled;
- }
-
- public void setEnabled(boolean enabled) {
- this.enabled = enabled;
- }
-
- /**
- * Returns true if the API URL is configured and not blank.
- */
- public boolean isConfigured() {
- String configuredUrl = getApiBaseUrl();
- return configuredUrl != null && !configuredUrl.trim().isEmpty();
- }
+public class JudoscaleConfig extends ConfigBase {
}
diff --git a/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleFilter.java b/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleFilter.java
index 7046fbc..de1d075 100644
--- a/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleFilter.java
+++ b/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleFilter.java
@@ -1,6 +1,7 @@
package com.judoscale.spring;
import com.judoscale.core.MetricsStore;
+import com.judoscale.core.QueueTimeCalculator;
import com.judoscale.core.UtilizationTracker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -23,11 +24,6 @@ public class JudoscaleFilter implements Filter {
private static final Logger logger = LoggerFactory.getLogger(JudoscaleFilter.class);
- // Cutoffs for determining the unit of the X-Request-Start header
- private static final long MILLISECONDS_CUTOFF = Instant.parse("2000-01-01T00:00:00Z").toEpochMilli();
- private static final long MICROSECONDS_CUTOFF = MILLISECONDS_CUTOFF * 1000;
- private static final long NANOSECONDS_CUTOFF = MICROSECONDS_CUTOFF * 1000;
-
private final MetricsStore metricsStore;
private final JudoscaleConfig config;
private final UtilizationTracker utilizationTracker;
@@ -56,7 +52,7 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
// Track queue time if header is present and request isn't too large
if (requestStartHeader != null && shouldTrackQueueTime(contentLength)) {
- long queueTimeMs = calculateQueueTime(requestStartHeader, now);
+ long queueTimeMs = QueueTimeCalculator.calculateQueueTime(requestStartHeader, now);
if (queueTimeMs >= 0) {
metricsStore.push("qt", queueTimeMs, now);
@@ -66,6 +62,8 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
logger.debug("Request queue_time={}ms request_id={} size={}",
queueTimeMs, requestId, contentLength);
+ } else {
+ logger.warn("Could not parse X-Request-Start header: {}", requestStartHeader);
}
}
@@ -95,65 +93,4 @@ private boolean shouldTrackQueueTime(int contentLength) {
}
return contentLength < 0 || contentLength <= config.getMaxRequestSizeBytes();
}
-
- /**
- * Calculates the queue time in milliseconds from the X-Request-Start header.
- * Handles multiple formats: seconds, milliseconds, microseconds, nanoseconds.
- */
- long calculateQueueTime(String requestStartHeader, Instant now) {
- try {
- // Strip any non-numeric characters (e.g., "t=" prefix from NGINX)
- String cleanValue = requestStartHeader.replaceAll("[^0-9.]", "");
-
- long startTimeMs;
-
- // Use long parsing for integer values to avoid precision loss with large timestamps
- // (nanosecond timestamps can exceed double's precision)
- if (!cleanValue.contains(".")) {
- long value = Long.parseLong(cleanValue);
- startTimeMs = convertToMillis(value);
- } else {
- // Fractional values (typically seconds from NGINX)
- double value = Double.parseDouble(cleanValue);
- if (value > NANOSECONDS_CUTOFF) {
- startTimeMs = (long) (value / 1_000_000);
- } else if (value > MICROSECONDS_CUTOFF) {
- startTimeMs = (long) (value / 1_000);
- } else if (value > MILLISECONDS_CUTOFF) {
- startTimeMs = (long) value;
- } else {
- // Seconds with fractional part
- startTimeMs = (long) (value * 1000);
- }
- }
-
- long queueTimeMs = now.toEpochMilli() - startTimeMs;
-
- // Safeguard against negative queue times
- return Math.max(0, queueTimeMs);
-
- } catch (NumberFormatException e) {
- logger.warn("Could not parse X-Request-Start header: {}", requestStartHeader);
- return -1;
- }
- }
-
- /**
- * Converts an integer timestamp to milliseconds based on its magnitude.
- */
- private long convertToMillis(long value) {
- if (value > NANOSECONDS_CUTOFF) {
- // Nanoseconds (Render)
- return value / 1_000_000;
- } else if (value > MICROSECONDS_CUTOFF) {
- // Microseconds
- return value / 1_000;
- } else if (value > MILLISECONDS_CUTOFF) {
- // Milliseconds (Heroku)
- return value;
- } else {
- // Seconds (integer seconds, rare but possible)
- return value * 1000;
- }
- }
}
diff --git a/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleReporter.java b/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleReporter.java
index 60cdf72..5602405 100644
--- a/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleReporter.java
+++ b/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleReporter.java
@@ -1,97 +1,18 @@
package com.judoscale.spring;
import com.judoscale.core.ApiClient;
-import com.judoscale.core.Metric;
import com.judoscale.core.MetricsStore;
+import com.judoscale.core.Reporter;
import com.judoscale.core.UtilizationTracker;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.time.Instant;
-import java.util.List;
-import java.util.concurrent.atomic.AtomicBoolean;
/**
- * Background reporter that sends collected metrics to the Judoscale API.
- * Runs on a fixed schedule (default: every 10 seconds).
+ * Spring Boot wrapper for the core Reporter.
+ * Provides the same functionality with Spring-compatible bean lifecycle.
*/
-public class JudoscaleReporter {
-
- private static final Logger logger = LoggerFactory.getLogger(JudoscaleReporter.class);
-
- private final MetricsStore metricsStore;
- private final ApiClient apiClient;
- private final JudoscaleConfig config;
- private final UtilizationTracker utilizationTracker;
- private final AtomicBoolean started = new AtomicBoolean(false);
+public class JudoscaleReporter extends Reporter {
public JudoscaleReporter(MetricsStore metricsStore, ApiClient apiClient, JudoscaleConfig config,
UtilizationTracker utilizationTracker) {
- this.metricsStore = metricsStore;
- this.apiClient = apiClient;
- this.config = config;
- this.utilizationTracker = utilizationTracker;
- }
-
- /**
- * Starts the reporter. Called automatically by Spring.
- */
- public void start() {
- if (!config.isConfigured()) {
- logger.info("Set judoscale.api-base-url (JUDOSCALE_URL) to enable metrics reporting");
- return;
- }
-
- if (started.compareAndSet(false, true)) {
- logger.info("Judoscale reporter starting, will report every ~{} seconds",
- config.getReportIntervalSeconds());
- }
- }
-
- /**
- * Reports metrics to the API. Called on a schedule.
- * The @Scheduled annotation is handled by JudoscaleAutoConfiguration.
- */
- public void reportMetrics() {
- if (!started.get() || !config.isConfigured()) {
- return;
- }
-
- try {
- // Collect utilization metric if tracker has been started
- if (utilizationTracker.isStarted()) {
- int utilizationPct = utilizationTracker.utilizationPct();
- metricsStore.push("up", utilizationPct, Instant.now());
- logger.debug("Collected utilization: {}%", utilizationPct);
- }
-
- List metrics = metricsStore.flush();
-
- if (metrics.isEmpty()) {
- logger.debug("No metrics to report");
- return;
- }
-
- logger.info("Reporting {} metrics", metrics.size());
- apiClient.reportMetrics(metrics);
-
- } catch (Exception e) {
- // Log the exception but don't rethrow - we want the scheduled task to continue
- logger.error("Reporter error: {}", e.getMessage(), e);
- }
- }
-
- /**
- * Returns whether the reporter has been started.
- */
- public boolean isStarted() {
- return started.get();
- }
-
- /**
- * Stops the reporter.
- */
- public void stop() {
- started.set(false);
+ super(metricsStore, apiClient, config, utilizationTracker);
}
}
diff --git a/judoscale-spring-boot-starter/src/main/java/com/judoscale/spring/JudoscaleApiClient.java b/judoscale-spring-boot-starter/src/main/java/com/judoscale/spring/JudoscaleApiClient.java
index 5bc6c48..2379595 100644
--- a/judoscale-spring-boot-starter/src/main/java/com/judoscale/spring/JudoscaleApiClient.java
+++ b/judoscale-spring-boot-starter/src/main/java/com/judoscale/spring/JudoscaleApiClient.java
@@ -1,23 +1,19 @@
package com.judoscale.spring;
+import com.judoscale.core.Adapter;
import com.judoscale.core.ApiClient;
import com.judoscale.core.Metric;
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.node.ArrayNode;
-import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.judoscale.core.ReportBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
-import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
-import java.util.Properties;
/**
* HTTP client for sending metrics to the Judoscale API.
@@ -25,9 +21,11 @@
public class JudoscaleApiClient implements ApiClient {
private static final Logger logger = LoggerFactory.getLogger(JudoscaleApiClient.class);
- private static final ObjectMapper objectMapper = new ObjectMapper();
private static final int MAX_RETRIES = 3;
- private static final String ADAPTER_VERSION = loadAdapterVersion();
+ private static final Adapter ADAPTER = new Adapter(
+ "judoscale-spring-boot",
+ ReportBuilder.loadAdapterVersion(JudoscaleApiClient.class)
+ );
private final JudoscaleConfig config;
private final HttpClient httpClient;
@@ -45,23 +43,6 @@ public JudoscaleApiClient(JudoscaleConfig config) {
this.httpClient = httpClient;
}
- /**
- * Loads the adapter version from the META-INF/judoscale.properties file.
- * Falls back to "unknown" if the file cannot be read.
- */
- private static String loadAdapterVersion() {
- try (InputStream is = JudoscaleApiClient.class.getResourceAsStream("/META-INF/judoscale.properties")) {
- if (is != null) {
- Properties props = new Properties();
- props.load(is);
- return props.getProperty("version", "unknown");
- }
- } catch (IOException e) {
- logger.debug("Could not load judoscale.properties: {}", e.getMessage());
- }
- return "unknown";
- }
-
@Override
public boolean reportMetrics(List metrics) {
if (!config.isConfigured()) {
@@ -69,7 +50,7 @@ public boolean reportMetrics(List metrics) {
return false;
}
- String json = buildReportJson(metrics);
+ String json = ReportBuilder.buildReportJson(metrics, List.of(ADAPTER));
String url = config.getApiBaseUrl() + "/v3/reports";
for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
@@ -116,38 +97,4 @@ public boolean reportMetrics(List metrics) {
return false;
}
-
- /**
- * Builds the JSON payload for the metrics report.
- */
- String buildReportJson(List metrics) {
- ObjectNode root = objectMapper.createObjectNode();
-
- // Build metrics array: each metric is [timestamp, value, identifier, queueName?]
- ArrayNode metricsArray = objectMapper.createArrayNode();
- for (Metric m : metrics) {
- ArrayNode metricArray = objectMapper.createArrayNode();
- metricArray.add(m.time().getEpochSecond());
- metricArray.add(m.value());
- metricArray.add(m.identifier());
- if (m.queueName() != null) {
- metricArray.add(m.queueName());
- }
- metricsArray.add(metricArray);
- }
- root.set("metrics", metricsArray);
-
- // Build adapters object
- ObjectNode adapters = objectMapper.createObjectNode();
- ObjectNode springBootAdapter = objectMapper.createObjectNode();
- springBootAdapter.put("adapter_version", ADAPTER_VERSION);
- adapters.set("judoscale-spring-boot", springBootAdapter);
- root.set("adapters", adapters);
-
- try {
- return objectMapper.writeValueAsString(root);
- } catch (JsonProcessingException e) {
- throw new RuntimeException("Failed to serialize metrics to JSON", e);
- }
- }
}
diff --git a/judoscale-spring-boot-starter/src/main/java/com/judoscale/spring/JudoscaleConfig.java b/judoscale-spring-boot-starter/src/main/java/com/judoscale/spring/JudoscaleConfig.java
index af166e3..7391651 100644
--- a/judoscale-spring-boot-starter/src/main/java/com/judoscale/spring/JudoscaleConfig.java
+++ b/judoscale-spring-boot-starter/src/main/java/com/judoscale/spring/JudoscaleConfig.java
@@ -1,5 +1,6 @@
package com.judoscale.spring;
+import com.judoscale.core.ConfigBase;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
@@ -14,110 +15,5 @@
*
*/
@ConfigurationProperties(prefix = "judoscale")
-public class JudoscaleConfig {
-
- /**
- * The base URL for the Judoscale API.
- * Typically set via JUDOSCALE_URL environment variable.
- */
- private String apiBaseUrl;
-
- /**
- * Alternative property for the API URL (maps to JUDOSCALE_URL env var via relaxed binding).
- */
- private String url;
-
- /**
- * How often to report metrics, in seconds. Default is 10.
- */
- private int reportIntervalSeconds = 10;
-
- /**
- * Maximum request body size in bytes before ignoring queue time.
- * Large requests can skew queue time measurements. Default is 100KB.
- */
- private int maxRequestSizeBytes = 100_000;
-
- /**
- * Whether to ignore queue time for large requests. Default is true.
- */
- private boolean ignoreLargeRequests = true;
-
- /**
- * Log level for Judoscale logging. Default is INFO.
- */
- private String logLevel = "INFO";
-
- /**
- * Whether Judoscale is enabled. Default is true.
- */
- private boolean enabled = true;
-
- public String getApiBaseUrl() {
- // Prefer explicit apiBaseUrl, fall back to url (which binds to JUDOSCALE_URL)
- if (apiBaseUrl != null && !apiBaseUrl.isBlank()) {
- return apiBaseUrl;
- }
- return url;
- }
-
- public void setApiBaseUrl(String apiBaseUrl) {
- this.apiBaseUrl = apiBaseUrl;
- }
-
- public String getUrl() {
- return url;
- }
-
- public void setUrl(String url) {
- this.url = url;
- }
-
- public int getReportIntervalSeconds() {
- return reportIntervalSeconds;
- }
-
- public void setReportIntervalSeconds(int reportIntervalSeconds) {
- this.reportIntervalSeconds = reportIntervalSeconds;
- }
-
- public int getMaxRequestSizeBytes() {
- return maxRequestSizeBytes;
- }
-
- public void setMaxRequestSizeBytes(int maxRequestSizeBytes) {
- this.maxRequestSizeBytes = maxRequestSizeBytes;
- }
-
- public boolean isIgnoreLargeRequests() {
- return ignoreLargeRequests;
- }
-
- public void setIgnoreLargeRequests(boolean ignoreLargeRequests) {
- this.ignoreLargeRequests = ignoreLargeRequests;
- }
-
- public String getLogLevel() {
- return logLevel;
- }
-
- public void setLogLevel(String logLevel) {
- this.logLevel = logLevel;
- }
-
- public boolean isEnabled() {
- return enabled;
- }
-
- public void setEnabled(boolean enabled) {
- this.enabled = enabled;
- }
-
- /**
- * Returns true if the API URL is configured and not blank.
- */
- public boolean isConfigured() {
- String configuredUrl = getApiBaseUrl();
- return configuredUrl != null && !configuredUrl.isBlank();
- }
+public class JudoscaleConfig extends ConfigBase {
}
diff --git a/judoscale-spring-boot-starter/src/main/java/com/judoscale/spring/JudoscaleFilter.java b/judoscale-spring-boot-starter/src/main/java/com/judoscale/spring/JudoscaleFilter.java
index d50a2f3..e5b8b58 100644
--- a/judoscale-spring-boot-starter/src/main/java/com/judoscale/spring/JudoscaleFilter.java
+++ b/judoscale-spring-boot-starter/src/main/java/com/judoscale/spring/JudoscaleFilter.java
@@ -1,6 +1,7 @@
package com.judoscale.spring;
import com.judoscale.core.MetricsStore;
+import com.judoscale.core.QueueTimeCalculator;
import com.judoscale.core.UtilizationTracker;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
@@ -23,11 +24,6 @@ public class JudoscaleFilter implements Filter {
private static final Logger logger = LoggerFactory.getLogger(JudoscaleFilter.class);
- // Cutoffs for determining the unit of the X-Request-Start header
- private static final long MILLISECONDS_CUTOFF = Instant.parse("2000-01-01T00:00:00Z").toEpochMilli();
- private static final long MICROSECONDS_CUTOFF = MILLISECONDS_CUTOFF * 1000;
- private static final long NANOSECONDS_CUTOFF = MICROSECONDS_CUTOFF * 1000;
-
private final MetricsStore metricsStore;
private final JudoscaleConfig config;
private final UtilizationTracker utilizationTracker;
@@ -54,7 +50,7 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
// Track queue time if header is present and request isn't too large
if (requestStartHeader != null && shouldTrackQueueTime(contentLength)) {
- long queueTimeMs = calculateQueueTime(requestStartHeader, now);
+ long queueTimeMs = QueueTimeCalculator.calculateQueueTime(requestStartHeader, now);
if (queueTimeMs >= 0) {
metricsStore.push("qt", queueTimeMs, now);
@@ -64,6 +60,8 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
logger.debug("Request queue_time={}ms request_id={} size={}",
queueTimeMs, requestId, contentLength);
+ } else {
+ logger.warn("Could not parse X-Request-Start header: {}", requestStartHeader);
}
}
@@ -93,65 +91,4 @@ private boolean shouldTrackQueueTime(int contentLength) {
}
return contentLength < 0 || contentLength <= config.getMaxRequestSizeBytes();
}
-
- /**
- * Calculates the queue time in milliseconds from the X-Request-Start header.
- * Handles multiple formats: seconds, milliseconds, microseconds, nanoseconds.
- */
- long calculateQueueTime(String requestStartHeader, Instant now) {
- try {
- // Strip any non-numeric characters (e.g., "t=" prefix from NGINX)
- String cleanValue = requestStartHeader.replaceAll("[^0-9.]", "");
-
- long startTimeMs;
-
- // Use long parsing for integer values to avoid precision loss with large timestamps
- // (nanosecond timestamps can exceed double's precision)
- if (!cleanValue.contains(".")) {
- long value = Long.parseLong(cleanValue);
- startTimeMs = convertToMillis(value);
- } else {
- // Fractional values (typically seconds from NGINX)
- double value = Double.parseDouble(cleanValue);
- if (value > NANOSECONDS_CUTOFF) {
- startTimeMs = (long) (value / 1_000_000);
- } else if (value > MICROSECONDS_CUTOFF) {
- startTimeMs = (long) (value / 1_000);
- } else if (value > MILLISECONDS_CUTOFF) {
- startTimeMs = (long) value;
- } else {
- // Seconds with fractional part
- startTimeMs = (long) (value * 1000);
- }
- }
-
- long queueTimeMs = now.toEpochMilli() - startTimeMs;
-
- // Safeguard against negative queue times
- return Math.max(0, queueTimeMs);
-
- } catch (NumberFormatException e) {
- logger.warn("Could not parse X-Request-Start header: {}", requestStartHeader);
- return -1;
- }
- }
-
- /**
- * Converts an integer timestamp to milliseconds based on its magnitude.
- */
- private long convertToMillis(long value) {
- if (value > NANOSECONDS_CUTOFF) {
- // Nanoseconds (Render)
- return value / 1_000_000;
- } else if (value > MICROSECONDS_CUTOFF) {
- // Microseconds
- return value / 1_000;
- } else if (value > MILLISECONDS_CUTOFF) {
- // Milliseconds (Heroku)
- return value;
- } else {
- // Seconds (integer seconds, rare but possible)
- return value * 1000;
- }
- }
}
diff --git a/judoscale-spring-boot-starter/src/main/java/com/judoscale/spring/JudoscaleReporter.java b/judoscale-spring-boot-starter/src/main/java/com/judoscale/spring/JudoscaleReporter.java
index efb4ee2..5602405 100644
--- a/judoscale-spring-boot-starter/src/main/java/com/judoscale/spring/JudoscaleReporter.java
+++ b/judoscale-spring-boot-starter/src/main/java/com/judoscale/spring/JudoscaleReporter.java
@@ -1,98 +1,18 @@
package com.judoscale.spring;
import com.judoscale.core.ApiClient;
-import com.judoscale.core.Metric;
import com.judoscale.core.MetricsStore;
+import com.judoscale.core.Reporter;
import com.judoscale.core.UtilizationTracker;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.scheduling.annotation.Scheduled;
-
-import java.time.Instant;
-import java.util.List;
-import java.util.concurrent.atomic.AtomicBoolean;
/**
- * Background reporter that sends collected metrics to the Judoscale API.
- * Runs on a fixed schedule (default: every 10 seconds).
+ * Spring Boot wrapper for the core Reporter.
+ * Provides the same functionality with Spring-compatible bean lifecycle.
*/
-public class JudoscaleReporter {
-
- private static final Logger logger = LoggerFactory.getLogger(JudoscaleReporter.class);
-
- private final MetricsStore metricsStore;
- private final ApiClient apiClient;
- private final JudoscaleConfig config;
- private final UtilizationTracker utilizationTracker;
- private final AtomicBoolean started = new AtomicBoolean(false);
+public class JudoscaleReporter extends Reporter {
public JudoscaleReporter(MetricsStore metricsStore, ApiClient apiClient, JudoscaleConfig config,
UtilizationTracker utilizationTracker) {
- this.metricsStore = metricsStore;
- this.apiClient = apiClient;
- this.config = config;
- this.utilizationTracker = utilizationTracker;
- }
-
- /**
- * Starts the reporter. Called automatically by Spring.
- */
- public void start() {
- if (!config.isConfigured()) {
- logger.info("Set judoscale.api-base-url (JUDOSCALE_URL) to enable metrics reporting");
- return;
- }
-
- if (started.compareAndSet(false, true)) {
- logger.info("Judoscale reporter starting, will report every ~{} seconds",
- config.getReportIntervalSeconds());
- }
- }
-
- /**
- * Reports metrics to the API. Called on a schedule.
- * The @Scheduled annotation is handled by JudoscaleAutoConfiguration.
- */
- public void reportMetrics() {
- if (!started.get() || !config.isConfigured()) {
- return;
- }
-
- try {
- // Collect utilization metric if tracker has been started
- if (utilizationTracker.isStarted()) {
- int utilizationPct = utilizationTracker.utilizationPct();
- metricsStore.push("up", utilizationPct, Instant.now());
- logger.debug("Collected utilization: {}%", utilizationPct);
- }
-
- List metrics = metricsStore.flush();
-
- if (metrics.isEmpty()) {
- logger.debug("No metrics to report");
- return;
- }
-
- logger.info("Reporting {} metrics", metrics.size());
- apiClient.reportMetrics(metrics);
-
- } catch (Exception e) {
- // Log the exception but don't rethrow - we want the scheduled task to continue
- logger.error("Reporter error: {}", e.getMessage(), e);
- }
- }
-
- /**
- * Returns whether the reporter has been started.
- */
- public boolean isStarted() {
- return started.get();
- }
-
- /**
- * Stops the reporter.
- */
- public void stop() {
- started.set(false);
+ super(metricsStore, apiClient, config, utilizationTracker);
}
}
diff --git a/judoscale-spring-boot-starter/src/test/java/com/judoscale/spring/JudoscaleApiClientTest.java b/judoscale-spring-boot-starter/src/test/java/com/judoscale/spring/JudoscaleApiClientTest.java
index 37c7c73..3275292 100644
--- a/judoscale-spring-boot-starter/src/test/java/com/judoscale/spring/JudoscaleApiClientTest.java
+++ b/judoscale-spring-boot-starter/src/test/java/com/judoscale/spring/JudoscaleApiClientTest.java
@@ -4,7 +4,6 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -21,44 +20,6 @@ void setUp() {
apiClient = new JudoscaleApiClient(config);
}
- @Test
- void buildReportJsonFormatsMetricsCorrectly() {
- Instant time = Instant.parse("2024-01-15T10:30:00Z");
- List metrics = List.of(
- new Metric("qt", 100, time),
- new Metric("at", 50, time)
- );
-
- String json = apiClient.buildReportJson(metrics);
-
- assertThat(json).contains("\"metrics\":");
- assertThat(json).contains("[1705314600,100,\"qt\"]");
- assertThat(json).contains("[1705314600,50,\"at\"]");
- assertThat(json).contains("\"adapters\":");
- assertThat(json).contains("\"judoscale-spring-boot\"");
- // Version should be included (loaded from META-INF/judoscale.properties or "unknown")
- assertThat(json).contains("\"adapter_version\":");
- }
-
- @Test
- void buildReportJsonIncludesQueueNameWhenPresent() {
- Instant time = Instant.parse("2024-01-15T10:30:00Z");
- List metrics = List.of(
- new Metric("qd", 5, time, "default")
- );
-
- String json = apiClient.buildReportJson(metrics);
-
- assertThat(json).contains("[1705314600,5,\"qd\",\"default\"]");
- }
-
- @Test
- void buildReportJsonHandlesEmptyMetricsList() {
- String json = apiClient.buildReportJson(List.of());
-
- assertThat(json).contains("\"metrics\":[]");
- }
-
@Test
void reportMetricsReturnsFalseWhenNotConfigured() {
config.setApiBaseUrl(null);