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): *

    *
  1. {@code judoscale.api-base-url} property
  2. @@ -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);