diff --git a/judoscale-core/build.gradle.kts b/judoscale-core/build.gradle.kts index 1d488df..118516c 100644 --- a/judoscale-core/build.gradle.kts +++ b/judoscale-core/build.gradle.kts @@ -12,6 +12,11 @@ java { targetCompatibility = JavaVersion.VERSION_1_8 } +// Use --release to enforce API compatibility at compile time +tasks.withType { + options.release.set(8) +} + dependencies { // JSON processing implementation(libs.jackson.databind) diff --git a/judoscale-core/src/main/java/com/judoscale/core/ConfigBase.java b/judoscale-core/src/main/java/com/judoscale/core/ConfigBase.java index 2663abc..c4771dc 100644 --- a/judoscale-core/src/main/java/com/judoscale/core/ConfigBase.java +++ b/judoscale-core/src/main/java/com/judoscale/core/ConfigBase.java @@ -9,6 +9,12 @@ */ public class ConfigBase { + /** + * The current runtime container identifier. + * Detected from environment variables at initialization. + */ + private final String runtimeContainer; + /** * The base URL for the Judoscale API. * Typically set via JUDOSCALE_URL environment variable. @@ -46,6 +52,75 @@ public class ConfigBase { */ private boolean enabled = true; + /** + * Creates a new ConfigBase, detecting the runtime container from environment variables. + */ + public ConfigBase() { + this.runtimeContainer = detectRuntimeContainer(); + } + + /** + * Detects the runtime container from various platform-specific environment variables. + * Checks in order: JUDOSCALE_CONTAINER, DYNO (Heroku), RENDER_INSTANCE_ID (Render), + * ECS_CONTAINER_METADATA_URI (AWS ECS), FLY_MACHINE_ID (Fly.io), RAILWAY_REPLICA_ID (Railway). + * + * @return the detected container identifier, or empty string if not detected + */ + private String detectRuntimeContainer() { + String container = System.getenv("JUDOSCALE_CONTAINER"); + if (container != null && !container.isEmpty()) { + return container; + } + + // Heroku + String dyno = System.getenv("DYNO"); + if (dyno != null && !dyno.isEmpty()) { + return dyno; + } + + // Render + String renderInstanceId = System.getenv("RENDER_INSTANCE_ID"); + if (renderInstanceId != null && !renderInstanceId.isEmpty()) { + String renderServiceId = System.getenv("RENDER_SERVICE_ID"); + if (renderServiceId != null && renderInstanceId.startsWith(renderServiceId + "-")) { + return renderInstanceId.substring(renderServiceId.length() + 1); + } + return renderInstanceId; + } + + // AWS ECS + String ecsMetadataUri = System.getenv("ECS_CONTAINER_METADATA_URI"); + if (ecsMetadataUri != null && !ecsMetadataUri.isEmpty()) { + int lastSlash = ecsMetadataUri.lastIndexOf('/'); + if (lastSlash >= 0 && lastSlash < ecsMetadataUri.length() - 1) { + return ecsMetadataUri.substring(lastSlash + 1); + } + } + + // Fly.io + String flyMachineId = System.getenv("FLY_MACHINE_ID"); + if (flyMachineId != null && !flyMachineId.isEmpty()) { + return flyMachineId; + } + + // Railway + String railwayReplicaId = System.getenv("RAILWAY_REPLICA_ID"); + if (railwayReplicaId != null && !railwayReplicaId.isEmpty()) { + return railwayReplicaId; + } + + return ""; + } + + /** + * Returns the current runtime container identifier. + * + * @return the runtime container, or empty string if not detected + */ + public String getRuntimeContainer() { + return runtimeContainer; + } + /** * Returns the API base URL, preferring explicit apiBaseUrl over url. */ diff --git a/judoscale-core/src/main/java/com/judoscale/core/ReportBuilder.java b/judoscale-core/src/main/java/com/judoscale/core/ReportBuilder.java index b4a44a6..9dcf968 100644 --- a/judoscale-core/src/main/java/com/judoscale/core/ReportBuilder.java +++ b/judoscale-core/src/main/java/com/judoscale/core/ReportBuilder.java @@ -7,6 +7,7 @@ import java.io.IOException; import java.io.InputStream; +import java.lang.management.ManagementFactory; import java.util.Collection; import java.util.List; import java.util.Properties; @@ -27,11 +28,16 @@ private ReportBuilder() { * * @param metrics the metrics to include in the report * @param adapters the adapters to include in the report (supports multiple adapters) + * @param runtimeContainer the runtime container identifier * @return the JSON string */ - public static String buildReportJson(List metrics, Collection adapters) { + public static String buildReportJson(List metrics, Collection adapters, String runtimeContainer) { ObjectNode root = objectMapper.createObjectNode(); + // Include runtime container identifier and process ID + root.put("container", runtimeContainer != null ? runtimeContainer : ""); + root.put("pid", getPid()); + // Build metrics array: each metric is [timestamp, value, identifier, queueName?] ArrayNode metricsArray = objectMapper.createArrayNode(); for (Metric m : metrics) { @@ -62,6 +68,21 @@ public static String buildReportJson(List metrics, Collection a } } + /** + * Gets the current process ID in a Java 8-compatible way. + * + * @return the process ID, or -1 if it cannot be determined + */ + private static long getPid() { + // RuntimeMXBean.getName() returns "pid@hostname" on most JVMs + String name = ManagementFactory.getRuntimeMXBean().getName(); + try { + return Long.parseLong(name.split("@")[0]); + } catch (NumberFormatException e) { + return -1; + } + } + /** * Loads the adapter version from the META-INF/judoscale.properties file. * Falls back to "unknown" if the file cannot be read. diff --git a/judoscale-core/src/test/java/com/judoscale/core/ConfigBaseTest.java b/judoscale-core/src/test/java/com/judoscale/core/ConfigBaseTest.java index a971739..9674125 100644 --- a/judoscale-core/src/test/java/com/judoscale/core/ConfigBaseTest.java +++ b/judoscale-core/src/test/java/com/judoscale/core/ConfigBaseTest.java @@ -83,4 +83,11 @@ void settersUpdateValues() { assertThat(config.getLogLevel()).isEqualTo("DEBUG"); assertThat(config.isEnabled()).isFalse(); } + + @Test + void runtimeContainerIsNeverNull() { + // Runtime container is detected from environment variables. + // In test environment without those vars set, it should return empty string, not null. + assertThat(config.getRuntimeContainer()).isNotNull(); + } } diff --git a/judoscale-core/src/test/java/com/judoscale/core/ReportBuilderTest.java b/judoscale-core/src/test/java/com/judoscale/core/ReportBuilderTest.java index a97de1e..21e96f0 100644 --- a/judoscale-core/src/test/java/com/judoscale/core/ReportBuilderTest.java +++ b/judoscale-core/src/test/java/com/judoscale/core/ReportBuilderTest.java @@ -21,8 +21,10 @@ void buildReportJsonFormatsMetricsCorrectly() { new Metric("at", 50, time) ); - String json = ReportBuilder.buildReportJson(metrics, Collections.singletonList(TEST_ADAPTER)); + String json = ReportBuilder.buildReportJson(metrics, Collections.singletonList(TEST_ADAPTER), "web.1"); + assertThat(json).contains("\"container\":\"web.1\""); + assertThat(json).containsPattern("\"pid\":\\d+"); assertThat(json).contains("\"metrics\":"); assertThat(json).contains("[1705314600,100,\"qt\"]"); assertThat(json).contains("[1705314600,50,\"at\"]"); @@ -38,16 +40,17 @@ void buildReportJsonIncludesQueueNameWhenPresent() { new Metric("qd", 5, time, "default") ); - String json = ReportBuilder.buildReportJson(metrics, Collections.singletonList(TEST_ADAPTER)); + String json = ReportBuilder.buildReportJson(metrics, Collections.singletonList(TEST_ADAPTER), "web.1"); assertThat(json).contains("[1705314600,5,\"qd\",\"default\"]"); } @Test void buildReportJsonHandlesEmptyMetricsList() { - String json = ReportBuilder.buildReportJson(Collections.emptyList(), Collections.singletonList(TEST_ADAPTER)); + String json = ReportBuilder.buildReportJson(Collections.emptyList(), Collections.singletonList(TEST_ADAPTER), "web.1"); assertThat(json).contains("\"metrics\":[]"); + assertThat(json).contains("\"container\":\"web.1\""); } @Test @@ -57,18 +60,32 @@ void buildReportJsonEscapesSpecialCharactersInQueueName() { new Metric("qd", 5, time, "queue\"with\\special") ); - String json = ReportBuilder.buildReportJson(metrics, Collections.singletonList(TEST_ADAPTER)); + String json = ReportBuilder.buildReportJson(metrics, Collections.singletonList(TEST_ADAPTER), "web.1"); assertThat(json).contains("\"queue\\\"with\\\\special\""); } + @Test + void buildReportJsonHandlesNullContainer() { + String json = ReportBuilder.buildReportJson(Collections.emptyList(), Collections.singletonList(TEST_ADAPTER), null); + + assertThat(json).contains("\"container\":\"\""); + } + + @Test + void buildReportJsonHandlesEmptyContainer() { + String json = ReportBuilder.buildReportJson(Collections.emptyList(), Collections.singletonList(TEST_ADAPTER), ""); + + assertThat(json).contains("\"container\":\"\""); + } + @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); + String json = ReportBuilder.buildReportJson(Collections.emptyList(), adapters, "web.1"); assertThat(json).contains("\"judoscale-spring-boot\""); assertThat(json).contains("\"judoscale-sidekiq\""); @@ -78,7 +95,7 @@ void buildReportJsonSupportsMultipleAdapters() { @Test void buildReportJsonHandlesEmptyAdaptersList() { - String json = ReportBuilder.buildReportJson(Collections.emptyList(), Collections.emptyList()); + String json = ReportBuilder.buildReportJson(Collections.emptyList(), Collections.emptyList(), "web.1"); assertThat(json).contains("\"adapters\":{}"); } diff --git a/judoscale-spring-boot-2-starter/build.gradle.kts b/judoscale-spring-boot-2-starter/build.gradle.kts index 29bb8b4..b4b39b5 100644 --- a/judoscale-spring-boot-2-starter/build.gradle.kts +++ b/judoscale-spring-boot-2-starter/build.gradle.kts @@ -12,6 +12,11 @@ java { targetCompatibility = JavaVersion.VERSION_1_8 } +// Use --release to enforce API compatibility at compile time +tasks.withType { + options.release.set(8) +} + // Generate version properties file during build tasks.register("generateVersionProperties") { val outputDir = layout.buildDirectory.dir("generated/resources") 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 f8ecb39..929bdac 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 @@ -63,7 +63,7 @@ public boolean reportMetrics(List metrics) { return false; } - String json = ReportBuilder.buildReportJson(metrics, Collections.singletonList(ADAPTER)); + String json = ReportBuilder.buildReportJson(metrics, Collections.singletonList(ADAPTER), config.getRuntimeContainer()); String url = config.getApiBaseUrl() + "/v3/reports"; for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { 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 2379595..fd56cb1 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 @@ -50,7 +50,7 @@ public boolean reportMetrics(List metrics) { return false; } - String json = ReportBuilder.buildReportJson(metrics, List.of(ADAPTER)); + String json = ReportBuilder.buildReportJson(metrics, List.of(ADAPTER), config.getRuntimeContainer()); String url = config.getApiBaseUrl() + "/v3/reports"; for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {