From 95d746c0dd6d323358fe987142f1919660e19e8b Mon Sep 17 00:00:00 2001 From: Adam McCrea Date: Thu, 29 Jan 2026 08:38:58 -0500 Subject: [PATCH 1/3] Add spring-boot-2-starter for Spring Boot 2.x --- README.md | 36 +++- build.gradle.kts | 1 + gradle/libs.versions.toml | 13 +- judoscale-core/build.gradle.kts | 6 + .../main/java/com/judoscale/core/Metric.java | 66 ++++++- judoscale-spring-boot-2-starter/.gitignore | 1 + .../build.gradle.kts | 99 +++++++++++ .../judoscale/spring/JudoscaleApiClient.java | 161 ++++++++++++++++++ .../spring/JudoscaleAutoConfiguration.java | 141 +++++++++++++++ .../com/judoscale/spring/JudoscaleConfig.java | 123 +++++++++++++ .../com/judoscale/spring/JudoscaleFilter.java | 159 +++++++++++++++++ .../judoscale/spring/JudoscaleReporter.java | 97 +++++++++++ ...itional-spring-configuration-metadata.json | 51 ++++++ .../main/resources/META-INF/spring.factories | 2 + sample-apps/spring-boot-2-sample/.gitignore | 37 ++++ .../spring-boot-2-sample/.tool-versions | 1 + sample-apps/spring-boot-2-sample/Procfile | 1 + sample-apps/spring-boot-2-sample/README.md | 33 ++++ .../spring-boot-2-sample/build.gradle.kts | 32 ++++ .../com/judoscale/sample/HomeController.java | 45 +++++ .../sample/SpringBoot2SampleApplication.java | 13 ++ .../src/main/resources/application.properties | 12 ++ .../src/main/resources/templates/home.html | 93 ++++++++++ .../SpringBoot2SampleApplicationTests.java | 13 ++ settings.gradle.kts | 2 + 25 files changed, 1230 insertions(+), 8 deletions(-) create mode 100644 judoscale-spring-boot-2-starter/.gitignore create mode 100644 judoscale-spring-boot-2-starter/build.gradle.kts create mode 100644 judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleApiClient.java create mode 100644 judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleAutoConfiguration.java create mode 100644 judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleConfig.java create mode 100644 judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleFilter.java create mode 100644 judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleReporter.java create mode 100644 judoscale-spring-boot-2-starter/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 judoscale-spring-boot-2-starter/src/main/resources/META-INF/spring.factories create mode 100644 sample-apps/spring-boot-2-sample/.gitignore create mode 100644 sample-apps/spring-boot-2-sample/.tool-versions create mode 100644 sample-apps/spring-boot-2-sample/Procfile create mode 100644 sample-apps/spring-boot-2-sample/README.md create mode 100644 sample-apps/spring-boot-2-sample/build.gradle.kts create mode 100644 sample-apps/spring-boot-2-sample/src/main/java/com/judoscale/sample/HomeController.java create mode 100644 sample-apps/spring-boot-2-sample/src/main/java/com/judoscale/sample/SpringBoot2SampleApplication.java create mode 100644 sample-apps/spring-boot-2-sample/src/main/resources/application.properties create mode 100644 sample-apps/spring-boot-2-sample/src/main/resources/templates/home.html create mode 100644 sample-apps/spring-boot-2-sample/src/test/java/com/judoscale/sample/SpringBoot2SampleApplicationTests.java diff --git a/README.md b/README.md index 15e481b..70ea5af 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,13 @@ Judoscale automatically scales your application based on request queue time and **Supported Frameworks** -- **Spring Boot** โ€” `judoscale-spring-boot-starter` +- **Spring Boot 3.x** โ€” `judoscale-spring-boot-starter` +- **Spring Boot 2.x** โ€” `judoscale-spring-boot-2-starter` ## judoscale-spring-boot-starter +For Spring Boot 3.x applications. + ### Requirements - Java 21 or later @@ -40,6 +43,37 @@ Add the dependency to your `build.gradle`: implementation 'com.judoscale:judoscale-spring-boot-starter:0.1.2' ``` +## judoscale-spring-boot-2-starter + +For Spring Boot 2.x applications (legacy support). + +### Requirements + +- Java 8 or later +- Spring Boot 2.6 or later (2.x series) + +### Installation + +#### Maven + +Add the dependency to your `pom.xml`: + +```xml + + com.judoscale + judoscale-spring-boot-2-starter + 0.1.2 + +``` + +#### Gradle + +Add the dependency to your `build.gradle`: + +```groovy +implementation 'com.judoscale:judoscale-spring-boot-2-starter:0.1.2' +``` + ### Usage #### Getting Started diff --git a/build.gradle.kts b/build.gradle.kts index 695b2a1..63d308b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,6 +26,7 @@ subprojects { group = "com.judoscale" version = projectVersion + // Default to Java 21, but allow subprojects to override java { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 206b575..d75384d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,19 +1,30 @@ [versions] spring-boot = "3.2.2" +spring-boot2 = "2.6.7" jackson = "2.16.1" slf4j = "2.0.11" junit = "5.10.1" assertj = "3.24.2" mockito = "5.8.0" byte-buddy = "1.14.11" +httpclient = "4.5.14" [libraries] -# Spring Boot +# Spring Boot 3.x spring-boot-autoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure", version.ref = "spring-boot" } spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" } spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot" } spring-boot-configuration-processor = { module = "org.springframework.boot:spring-boot-configuration-processor", version.ref = "spring-boot" } +# Spring Boot 2.x +spring-boot2-autoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure", version.ref = "spring-boot2" } +spring-boot2-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot2" } +spring-boot2-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot2" } +spring-boot2-configuration-processor = { module = "org.springframework.boot:spring-boot-configuration-processor", version.ref = "spring-boot2" } + +# HTTP Client (for Java 8 compatibility) +httpclient = { module = "org.apache.httpcomponents:httpclient", version.ref = "httpclient" } + # Jackson jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } diff --git a/judoscale-core/build.gradle.kts b/judoscale-core/build.gradle.kts index e51cc59..2ded615 100644 --- a/judoscale-core/build.gradle.kts +++ b/judoscale-core/build.gradle.kts @@ -6,6 +6,12 @@ plugins { description = "Core library for Judoscale Java integrations" +// judoscale-core targets Java 8 for maximum compatibility +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + dependencies { // Testing testImplementation(libs.junit.jupiter) diff --git a/judoscale-core/src/main/java/com/judoscale/core/Metric.java b/judoscale-core/src/main/java/com/judoscale/core/Metric.java index f14e2c7..9607885 100644 --- a/judoscale-core/src/main/java/com/judoscale/core/Metric.java +++ b/judoscale-core/src/main/java/com/judoscale/core/Metric.java @@ -1,17 +1,29 @@ package com.judoscale.core; import java.time.Instant; +import java.util.Objects; /** * Represents a single metric measurement. * Metrics: qt = queue time, at = application time, nt = network time, up = utilization percentage */ -public record Metric( - String identifier, - long value, - Instant time, - String queueName -) { +public final class Metric { + + private final String identifier; + private final long value; + private final Instant time; + private final String queueName; + + /** + * Creates a metric with all fields. + */ + public Metric(String identifier, long value, Instant time, String queueName) { + this.identifier = identifier; + this.value = value; + this.time = time; + this.queueName = queueName; + } + /** * Creates a web request metric (no queue name). */ @@ -25,4 +37,46 @@ public Metric(String identifier, long value, Instant time) { public Metric(String identifier, long value) { this(identifier, value, Instant.now(), null); } + + public String identifier() { + return identifier; + } + + public long value() { + return value; + } + + public Instant time() { + return time; + } + + public String queueName() { + return queueName; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Metric metric = (Metric) o; + return value == metric.value && + Objects.equals(identifier, metric.identifier) && + Objects.equals(time, metric.time) && + Objects.equals(queueName, metric.queueName); + } + + @Override + public int hashCode() { + return Objects.hash(identifier, value, time, queueName); + } + + @Override + public String toString() { + return "Metric{" + + "identifier='" + identifier + '\'' + + ", value=" + value + + ", time=" + time + + ", queueName='" + queueName + '\'' + + '}'; + } } diff --git a/judoscale-spring-boot-2-starter/.gitignore b/judoscale-spring-boot-2-starter/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/judoscale-spring-boot-2-starter/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/judoscale-spring-boot-2-starter/build.gradle.kts b/judoscale-spring-boot-2-starter/build.gradle.kts new file mode 100644 index 0000000..29bb8b4 --- /dev/null +++ b/judoscale-spring-boot-2-starter/build.gradle.kts @@ -0,0 +1,99 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.maven.publish) +} + +description = "Autoscaling for Spring Boot 2.x applications on Heroku, AWS, and other cloud hosts" + +// Spring Boot 2 starter targets Java 8 +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +// Generate version properties file during build +tasks.register("generateVersionProperties") { + val outputDir = layout.buildDirectory.dir("generated/resources") + val versionValue = version.toString() + + outputs.dir(outputDir) + + doLast { + val propsDir = outputDir.get().asFile.resolve("META-INF") + propsDir.mkdirs() + propsDir.resolve("judoscale.properties").writeText("version=$versionValue\n") + } +} + +tasks.named("processResources") { + dependsOn("generateVersionProperties") + from(layout.buildDirectory.dir("generated/resources")) +} + +dependencies { + // Judoscale Core + api(project(":judoscale-core")) + + // Spring Boot 2.x Web (provided - the app will have this) + compileOnly(libs.spring.boot2.starter.web) + testImplementation(libs.spring.boot2.starter.web) + + // Spring Boot 2.x Auto-configuration + implementation(libs.spring.boot2.autoconfigure) + + // For @ConfigurationProperties + annotationProcessor(libs.spring.boot2.configuration.processor) + + // JSON processing (for API client) + implementation(libs.jackson.databind) + + // HTTP client for Java 8 (Apache HttpClient) + implementation(libs.httpclient) + + // Logging + implementation(libs.slf4j.api) + + // Testing + testImplementation(libs.spring.boot2.starter.test) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertj.core) + testImplementation(libs.mockito.core) + testImplementation(libs.mockito.junit.jupiter) +} + +mavenPublishing { + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true) + signAllPublications() + + coordinates(group.toString(), "judoscale-spring-boot-2-starter", version.toString()) + + pom { + name.set("Judoscale Spring Boot 2 Starter") + description.set(project.description) + inceptionYear.set("2024") + url.set("https://github.com/judoscale/judoscale-java") + + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + distribution.set("https://opensource.org/licenses/MIT") + } + } + + developers { + developer { + id.set("judoscale") + name.set("Judoscale") + email.set("support@judoscale.com") + } + } + + scm { + connection.set("scm:git:git://github.com/judoscale/judoscale-java.git") + developerConnection.set("scm:git:ssh://github.com/judoscale/judoscale-java.git") + url.set("https://github.com/judoscale/judoscale-java") + } + } +} 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 new file mode 100644 index 0000000..c7d5402 --- /dev/null +++ b/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleApiClient.java @@ -0,0 +1,161 @@ +package com.judoscale.spring; + +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 org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Properties; + +/** + * HTTP client for sending metrics to the Judoscale API. + * Uses Apache HttpClient for Java 8 compatibility. + */ +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 final JudoscaleConfig config; + private final CloseableHttpClient httpClient; + + public JudoscaleApiClient(JudoscaleConfig config) { + this.config = config; + + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(5000) + .setSocketTimeout(10000) + .setConnectionRequestTimeout(5000) + .build(); + + this.httpClient = HttpClients.custom() + .setDefaultRequestConfig(requestConfig) + .build(); + } + + // Constructor for testing with mock HttpClient + JudoscaleApiClient(JudoscaleConfig config, CloseableHttpClient httpClient) { + this.config = 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()) { + logger.debug("Judoscale API URL not configured, skipping report"); + return false; + } + + String json = buildReportJson(metrics); + String url = config.getApiBaseUrl() + "/v3/reports"; + + for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + HttpPost request = new HttpPost(url); + request.setHeader("Content-Type", "application/json"); + request.setEntity(new StringEntity(json, ContentType.APPLICATION_JSON)); + + logger.debug("Posting {} bytes to {}", json.length(), url); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + int statusCode = response.getStatusLine().getStatusCode(); + String responseBody = response.getEntity() != null + ? EntityUtils.toString(response.getEntity()) + : ""; + + if (statusCode >= 200 && statusCode < 300) { + logger.debug("Reported successfully"); + return true; + } else { + logger.error("Reporter failed: {} - {}", statusCode, responseBody); + return false; + } + } + + } catch (IOException e) { + if (attempt < MAX_RETRIES) { + logger.debug("Retry {} after error: {}", attempt, e.getMessage()); + try { + Thread.sleep(10); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + return false; + } + } else { + logger.error("Could not connect to {}: {}", url, e.getMessage()); + return false; + } + } + } + + 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-2-starter/src/main/java/com/judoscale/spring/JudoscaleAutoConfiguration.java b/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleAutoConfiguration.java new file mode 100644 index 0000000..71e0d18 --- /dev/null +++ b/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleAutoConfiguration.java @@ -0,0 +1,141 @@ +package com.judoscale.spring; + +import com.judoscale.core.MetricsStore; +import com.judoscale.core.UtilizationTracker; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.concurrent.ScheduledFuture; + +/** + * Auto-configuration for Judoscale Spring Boot integration. + * Automatically registers the filter and reporter when the starter is on the classpath. + */ +@Configuration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnProperty(name = "judoscale.enabled", havingValue = "true", matchIfMissing = true) +@EnableConfigurationProperties(JudoscaleConfig.class) +public class JudoscaleAutoConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(JudoscaleAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean(MetricsStore.class) + public MetricsStore judoscaleMetricsStore() { + return new MetricsStore(); + } + + @Bean + @ConditionalOnMissingBean(UtilizationTracker.class) + public UtilizationTracker judoscaleUtilizationTracker() { + return new UtilizationTracker(); + } + + @Bean + @ConditionalOnMissingBean(JudoscaleApiClient.class) + public JudoscaleApiClient judoscaleApiClient(JudoscaleConfig config) { + return new JudoscaleApiClient(config); + } + + @Bean + @ConditionalOnMissingBean(JudoscaleReporter.class) + public JudoscaleReporter judoscaleReporter( + MetricsStore metricsStore, + JudoscaleApiClient apiClient, + JudoscaleConfig config, + UtilizationTracker utilizationTracker) { + return new JudoscaleReporter(metricsStore, apiClient, config, utilizationTracker); + } + + @Bean + @ConditionalOnMissingBean(name = "judoscaleFilter") + public FilterRegistrationBean judoscaleFilter( + MetricsStore metricsStore, + JudoscaleConfig config, + UtilizationTracker utilizationTracker) { + + FilterRegistrationBean registration = new FilterRegistrationBean(); + registration.setFilter(new JudoscaleFilter(metricsStore, config, utilizationTracker)); + registration.addUrlPatterns("/*"); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE); + registration.setName("judoscaleFilter"); + + return registration; + } + + /** + * Dedicated task scheduler for Judoscale to avoid conflicts with application scheduling. + */ + @Bean(destroyMethod = "shutdown") + @ConditionalOnMissingBean(name = "judoscaleTaskScheduler") + public ThreadPoolTaskScheduler judoscaleTaskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("judoscale-"); + scheduler.setDaemon(true); + scheduler.initialize(); + return scheduler; + } + + /** + * Scheduler component that triggers metric reporting. + * Uses programmatic scheduling instead of @Scheduled for Spring Boot 2.6 compatibility. + */ + @Bean + @ConditionalOnMissingBean(JudoscaleScheduler.class) + public JudoscaleScheduler judoscaleScheduler( + JudoscaleReporter reporter, + JudoscaleConfig config, + TaskScheduler judoscaleTaskScheduler) { + return new JudoscaleScheduler(reporter, config, judoscaleTaskScheduler); + } + + /** + * Inner class to handle scheduling programmatically for Spring Boot 2.6 compatibility. + */ + public static class JudoscaleScheduler { + + private final JudoscaleReporter reporter; + private final JudoscaleConfig config; + private final TaskScheduler taskScheduler; + private ScheduledFuture scheduledTask; + + public JudoscaleScheduler(JudoscaleReporter reporter, JudoscaleConfig config, TaskScheduler taskScheduler) { + this.reporter = reporter; + this.config = config; + this.taskScheduler = taskScheduler; + } + + @PostConstruct + public void init() { + reporter.start(); + long intervalMs = config.getReportIntervalSeconds() * 1000L; + scheduledTask = taskScheduler.scheduleAtFixedRate(new Runnable() { + @Override + public void run() { + reporter.reportMetrics(); + } + }, intervalMs); + } + + @PreDestroy + public void destroy() { + if (scheduledTask != null) { + scheduledTask.cancel(false); + } + } + } +} 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 new file mode 100644 index 0000000..b293956 --- /dev/null +++ b/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleConfig.java @@ -0,0 +1,123 @@ +package com.judoscale.spring; + +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. + *
  3. {@code judoscale.url} property
  4. + *
  5. {@code JUDOSCALE_URL} environment variable (via Spring's relaxed binding)
  6. + *
+ */ +@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(); + } +} 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 new file mode 100644 index 0000000..7046fbc --- /dev/null +++ b/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleFilter.java @@ -0,0 +1,159 @@ +package com.judoscale.spring; + +import com.judoscale.core.MetricsStore; +import com.judoscale.core.UtilizationTracker; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.time.Instant; + +/** + * Servlet filter that measures request queue time and application time. + * Queue time is calculated from the X-Request-Start header set by the load balancer. + * Also tracks request utilization via UtilizationTracker. + */ +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; + + public JudoscaleFilter(MetricsStore metricsStore, JudoscaleConfig config, UtilizationTracker utilizationTracker) { + this.metricsStore = metricsStore; + this.config = config; + this.utilizationTracker = utilizationTracker; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + if (!(request instanceof HttpServletRequest)) { + chain.doFilter(request, response); + return; + } + + HttpServletRequest httpRequest = (HttpServletRequest) request; + + Instant now = Instant.now(); + String requestStartHeader = httpRequest.getHeader("X-Request-Start"); + String requestId = httpRequest.getHeader("X-Request-Id"); + int contentLength = httpRequest.getContentLength(); + + // Track queue time if header is present and request isn't too large + if (requestStartHeader != null && shouldTrackQueueTime(contentLength)) { + long queueTimeMs = calculateQueueTime(requestStartHeader, now); + + if (queueTimeMs >= 0) { + metricsStore.push("qt", queueTimeMs, now); + + // Expose queue time to the application via request attribute + httpRequest.setAttribute("judoscale.queue_time", queueTimeMs); + + logger.debug("Request queue_time={}ms request_id={} size={}", + queueTimeMs, requestId, contentLength); + } + } + + // Start utilization tracking on first request (lazy initialization) + utilizationTracker.start(); + utilizationTracker.incr(); + + // Measure application time + long startNanos = System.nanoTime(); + + try { + chain.doFilter(request, response); + } finally { + long appTimeMs = (System.nanoTime() - startNanos) / 1_000_000; + metricsStore.push("at", appTimeMs, now); + utilizationTracker.decr(); + } + } + + /** + * Determines if we should track queue time based on request size. + * Large requests can skew queue time due to network transfer time. + */ + private boolean shouldTrackQueueTime(int contentLength) { + if (!config.isIgnoreLargeRequests()) { + return true; + } + 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 new file mode 100644 index 0000000..8e686a7 --- /dev/null +++ b/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleReporter.java @@ -0,0 +1,97 @@ +package com.judoscale.spring; + +import com.judoscale.core.ApiClient; +import com.judoscale.core.Metric; +import com.judoscale.core.MetricsStore; +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). + */ +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 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.debug("Set judoscale.api-base-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); + } +} diff --git a/judoscale-spring-boot-2-starter/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/judoscale-spring-boot-2-starter/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..da46095 --- /dev/null +++ b/judoscale-spring-boot-2-starter/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,51 @@ +{ + "properties": [ + { + "name": "judoscale.enabled", + "type": "java.lang.Boolean", + "description": "Whether Judoscale metrics collection is enabled.", + "defaultValue": true + }, + { + "name": "judoscale.api-base-url", + "type": "java.lang.String", + "description": "The Judoscale API URL. Typically set via the JUDOSCALE_URL environment variable." + }, + { + "name": "judoscale.report-interval-seconds", + "type": "java.lang.Integer", + "description": "How often to report metrics to Judoscale, in seconds.", + "defaultValue": 10 + }, + { + "name": "judoscale.max-request-size-bytes", + "type": "java.lang.Integer", + "description": "Maximum request body size in bytes before ignoring queue time. Large requests can skew measurements.", + "defaultValue": 100000 + }, + { + "name": "judoscale.ignore-large-requests", + "type": "java.lang.Boolean", + "description": "Whether to ignore queue time measurements for requests larger than max-request-size-bytes.", + "defaultValue": true + }, + { + "name": "judoscale.log-level", + "type": "java.lang.String", + "description": "Log level for Judoscale logging.", + "defaultValue": "INFO" + } + ], + "hints": [ + { + "name": "judoscale.log-level", + "values": [ + { "value": "TRACE" }, + { "value": "DEBUG" }, + { "value": "INFO" }, + { "value": "WARN" }, + { "value": "ERROR" } + ] + } + ] +} diff --git a/judoscale-spring-boot-2-starter/src/main/resources/META-INF/spring.factories b/judoscale-spring-boot-2-starter/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..c19f881 --- /dev/null +++ b/judoscale-spring-boot-2-starter/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +com.judoscale.spring.JudoscaleAutoConfiguration diff --git a/sample-apps/spring-boot-2-sample/.gitignore b/sample-apps/spring-boot-2-sample/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/sample-apps/spring-boot-2-sample/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/sample-apps/spring-boot-2-sample/.tool-versions b/sample-apps/spring-boot-2-sample/.tool-versions new file mode 100644 index 0000000..901722d --- /dev/null +++ b/sample-apps/spring-boot-2-sample/.tool-versions @@ -0,0 +1 @@ +java zulu-8.78.0.19 diff --git a/sample-apps/spring-boot-2-sample/Procfile b/sample-apps/spring-boot-2-sample/Procfile new file mode 100644 index 0000000..248d6d7 --- /dev/null +++ b/sample-apps/spring-boot-2-sample/Procfile @@ -0,0 +1 @@ +web: java -jar build/libs/spring-boot-2-sample-0.0.1-SNAPSHOT.jar --server.port=$PORT diff --git a/sample-apps/spring-boot-2-sample/README.md b/sample-apps/spring-boot-2-sample/README.md new file mode 100644 index 0000000..847b27c --- /dev/null +++ b/sample-apps/spring-boot-2-sample/README.md @@ -0,0 +1,33 @@ +# Spring Boot 2 Sample Application + +This sample application demonstrates the `judoscale-spring-boot-2-starter` integration with Spring Boot 2.6.7 and Java 8. + +## Requirements + +- Java 8 +- Gradle (wrapper included) + +## Running + +From this directory: + +```bash +./bin/dev +``` + +Or from the project root: + +```bash +./gradlew :sample-apps:spring-boot-2-sample:bootRun +``` + +Then visit http://localhost:8080 + +## Testing Metrics + +1. Visit http://localhost:8080 +2. Open https://judoscale-java-sb2.requestcatcher.com in another tab +3. Make requests to the sample app +4. Watch metrics appear in the request catcher every 10 seconds + +Use the `?sleep=N` parameter to simulate slow requests (e.g., `/?sleep=2` for a 2-second delay). diff --git a/sample-apps/spring-boot-2-sample/build.gradle.kts b/sample-apps/spring-boot-2-sample/build.gradle.kts new file mode 100644 index 0000000..6ed133c --- /dev/null +++ b/sample-apps/spring-boot-2-sample/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + java + id("org.springframework.boot") version "2.6.7" + id("io.spring.dependency-management") version "1.0.15.RELEASE" +} + +group = "com.judoscale" +version = "0.0.1-SNAPSHOT" + +description = "Sample app for testing judoscale-spring-boot-2-starter" + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") + + // Judoscale Spring Boot 2 Starter + implementation(project(":judoscale-spring-boot-2-starter")) + + // Development tools (auto-restart on file changes) + developmentOnly("org.springframework.boot:spring-boot-devtools") + + testImplementation("org.springframework.boot:spring-boot-starter-test") +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/sample-apps/spring-boot-2-sample/src/main/java/com/judoscale/sample/HomeController.java b/sample-apps/spring-boot-2-sample/src/main/java/com/judoscale/sample/HomeController.java new file mode 100644 index 0000000..f9a10c0 --- /dev/null +++ b/sample-apps/spring-boot-2-sample/src/main/java/com/judoscale/sample/HomeController.java @@ -0,0 +1,45 @@ +package com.judoscale.sample; + +import com.judoscale.spring.JudoscaleConfig; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; + +@Controller +public class HomeController { + + private final JudoscaleConfig judoscaleConfig; + + public HomeController(JudoscaleConfig judoscaleConfig) { + this.judoscaleConfig = judoscaleConfig; + } + + @GetMapping("/") + public String home( + @RequestParam(name = "sleep", required = false) Double sleepSeconds, + Model model) throws InterruptedException { + + long startTime = System.currentTimeMillis(); + + if (sleepSeconds != null && sleepSeconds > 0) { + long sleepMillis = (long) (sleepSeconds * 1000); + Thread.sleep(sleepMillis); + } + + long duration = System.currentTimeMillis() - startTime; + + model.addAttribute("apiBaseUrl", judoscaleConfig.getApiBaseUrl()); + model.addAttribute("sleepSeconds", sleepSeconds); + model.addAttribute("requestDuration", duration); + + return "home"; + } + + @GetMapping("/health") + @ResponseBody + public String health() { + return "OK"; + } +} diff --git a/sample-apps/spring-boot-2-sample/src/main/java/com/judoscale/sample/SpringBoot2SampleApplication.java b/sample-apps/spring-boot-2-sample/src/main/java/com/judoscale/sample/SpringBoot2SampleApplication.java new file mode 100644 index 0000000..3cc45dd --- /dev/null +++ b/sample-apps/spring-boot-2-sample/src/main/java/com/judoscale/sample/SpringBoot2SampleApplication.java @@ -0,0 +1,13 @@ +package com.judoscale.sample; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpringBoot2SampleApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringBoot2SampleApplication.class, args); + } + +} diff --git a/sample-apps/spring-boot-2-sample/src/main/resources/application.properties b/sample-apps/spring-boot-2-sample/src/main/resources/application.properties new file mode 100644 index 0000000..2ef3201 --- /dev/null +++ b/sample-apps/spring-boot-2-sample/src/main/resources/application.properties @@ -0,0 +1,12 @@ +# Judoscale configuration +# Use request catcher for local testing +judoscale.api-base-url=https://judoscale-java-sb2.requestcatcher.com/api + +# Report every 10 seconds (default) +judoscale.report-interval-seconds=10 + +# Development: disable template caching for live reload +spring.thymeleaf.cache=false + +# Logging +logging.level.com.judoscale=DEBUG diff --git a/sample-apps/spring-boot-2-sample/src/main/resources/templates/home.html b/sample-apps/spring-boot-2-sample/src/main/resources/templates/home.html new file mode 100644 index 0000000..617422c --- /dev/null +++ b/sample-apps/spring-boot-2-sample/src/main/resources/templates/home.html @@ -0,0 +1,93 @@ + + + + + + Judoscale: Spring Boot 2 Sample + + + +
+

Judoscale Spring Boot 2 Sample

+ +

+ Judoscale is reporting web request metrics to + API URL. +

+

+ Reload this page to generate metrics, and watch them appear in the + request catcher. +

+ +
+ Slept for 0 second(s). Total + request duration: 0ms +
+ +
+
Test Request Duration
+

Simulate slow requests to see how Judoscale tracks request times.

+
+
+ + +
+
+

+ Quick links: + 0.5s + 1s + 2s + 5s +

+
+ +

How It Works

+
    +
  1. + Each request to this page is tracked by Judoscale. +
  2. +
  3. Metrics like queue time and request duration are collected.
  4. +
  5. Metrics are reported to the API endpoint every 10 seconds.
  6. +
  7. + Use the ?sleep=N parameter to simulate slow requests. +
  8. +
+ +
+ Tip: Open the + API endpoint + in another tab to watch metrics as they're reported in real-time. +
+ + +
+ + diff --git a/sample-apps/spring-boot-2-sample/src/test/java/com/judoscale/sample/SpringBoot2SampleApplicationTests.java b/sample-apps/spring-boot-2-sample/src/test/java/com/judoscale/sample/SpringBoot2SampleApplicationTests.java new file mode 100644 index 0000000..f8f426c --- /dev/null +++ b/sample-apps/spring-boot-2-sample/src/test/java/com/judoscale/sample/SpringBoot2SampleApplicationTests.java @@ -0,0 +1,13 @@ +package com.judoscale.sample; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SpringBoot2SampleApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 4fc09f2..734c346 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,4 +2,6 @@ rootProject.name = "judoscale-java" include("judoscale-core") include("judoscale-spring-boot-starter") +include("judoscale-spring-boot-2-starter") include("sample-apps:spring-boot-sample") +include("sample-apps:spring-boot-2-sample") From c83e8bc10b5bc593acdd72e475f369b08bdfa948 Mon Sep 17 00:00:00 2001 From: Adam McCrea Date: Thu, 29 Jan 2026 08:46:49 -0500 Subject: [PATCH 2/3] update release workflow --- .github/workflows/release.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e569b25..85db026 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: dry_run: - description: 'Dry run (skip actual publish)' + description: "Dry run (skip actual publish)" type: boolean default: true @@ -36,10 +36,11 @@ jobs: if: ${{ steps.release.outputs.pr != '' }} run: | VERSION=$(cat version.txt) - # Update Maven example + # Update Maven examples (all version tags) sed -i "s|[0-9]*\.[0-9]*\.[0-9]*|${VERSION}|g" README.md - # Update Gradle example + # Update Gradle examples (both starters) sed -i "s|judoscale-spring-boot-starter:[0-9]*\.[0-9]*\.[0-9]*|judoscale-spring-boot-starter:${VERSION}|g" README.md + sed -i "s|judoscale-spring-boot-2-starter:[0-9]*\.[0-9]*\.[0-9]*|judoscale-spring-boot-2-starter:${VERSION}|g" README.md - name: Commit version updates if: ${{ steps.release.outputs.pr != '' }} @@ -63,8 +64,8 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '21' - distribution: 'temurin' + java-version: "21" + distribution: "temurin" - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 From e569a0d74a505340c94707829fc0b80b56e3e63e Mon Sep 17 00:00:00 2001 From: Adam McCrea Date: Thu, 29 Jan 2026 09:42:11 -0500 Subject: [PATCH 3/3] properly close JudoscaleApiClient --- .../com/judoscale/spring/JudoscaleApiClient.java | 15 ++++++++++++++- .../spring/JudoscaleAutoConfiguration.java | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) 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 c7d5402..6fc5004 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 @@ -17,6 +17,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.util.List; @@ -26,7 +27,7 @@ * HTTP client for sending metrics to the Judoscale API. * Uses Apache HttpClient for Java 8 compatibility. */ -public class JudoscaleApiClient implements ApiClient { +public class JudoscaleApiClient implements ApiClient, Closeable { private static final Logger logger = LoggerFactory.getLogger(JudoscaleApiClient.class); private static final ObjectMapper objectMapper = new ObjectMapper(); @@ -158,4 +159,16 @@ String buildReportJson(List metrics) { 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. + */ + @Override + public void close() throws IOException { + if (httpClient != null) { + httpClient.close(); + logger.debug("HTTP client closed"); + } + } } diff --git a/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleAutoConfiguration.java b/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleAutoConfiguration.java index 71e0d18..291b5e3 100644 --- a/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleAutoConfiguration.java +++ b/judoscale-spring-boot-2-starter/src/main/java/com/judoscale/spring/JudoscaleAutoConfiguration.java @@ -44,7 +44,7 @@ public UtilizationTracker judoscaleUtilizationTracker() { return new UtilizationTracker(); } - @Bean + @Bean(destroyMethod = "close") @ConditionalOnMissingBean(JudoscaleApiClient.class) public JudoscaleApiClient judoscaleApiClient(JudoscaleConfig config) { return new JudoscaleApiClient(config);