diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 499ced67c..719183c83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,11 @@ on: - main workflow_dispatch: +permissions: + contents: read + checks: write + pull-requests: write + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/INTERCEPTOR.md b/INTERCEPTOR.md index 14d603703..81fe78515 100644 --- a/INTERCEPTOR.md +++ b/INTERCEPTOR.md @@ -399,10 +399,14 @@ import com.netflix.conductor.client.http.ConductorClient; import com.netflix.conductor.client.http.TaskClient; import com.netflix.conductor.client.http.WorkflowClient; import com.netflix.conductor.client.metrics.prometheus.PrometheusMetricsCollector; +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; -// 1. Create and start metrics -PrometheusMetricsCollector metricsCollector = new PrometheusMetricsCollector(); -metricsCollector.startServer(); // port 9991, /metrics +// 1. Create a registry and a collector bound to it. The SDK does not start a +// web server; expose registry.scrape() from an endpoint you own (or, in +// Spring Boot, hand in the app's MeterRegistry and use Actuator). +PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); +PrometheusMetricsCollector metricsCollector = new PrometheusMetricsCollector(registry); // 2. Create ConductorClient — withMetricsCollector installs the HTTP interceptor // and enables automatic listener registration on downstream clients @@ -427,12 +431,23 @@ For fine-grained control over which listeners are registered, create the `Conduc ### Custom Metrics Endpoint +The SDK no longer serves metrics itself. Expose the registry from an endpoint your application owns: + ```java -// Start Prometheus server on custom port and endpoint -PrometheusMetricsCollector metricsCollector = new PrometheusMetricsCollector(); -metricsCollector.startServer(8080, "/custom-metrics"); +PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); +PrometheusMetricsCollector metricsCollector = new PrometheusMetricsCollector(registry); + +var server = com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress(8080), 0); +server.createContext("/custom-metrics", exchange -> { + byte[] body = registry.scrape().getBytes(); + exchange.sendResponseHeaders(200, body.length); + try (var os = exchange.getResponseBody()) { os.write(body); } +}); +server.start(); ``` +In Spring Boot, prefer Actuator: hand the application's `MeterRegistry` to the collector (done automatically by `ConductorMetricsAutoConfiguration`) and configure the scrape path/port with `management.*` properties. + ### Registering Custom Event Listeners #### Approach 1: Using Builder's Listener API @@ -908,8 +923,9 @@ public class CloudWatchMetricsCollector implements MetricsCollector { ```java // Create multiple collectors -PrometheusMetricsCollector prometheus = new PrometheusMetricsCollector(); -prometheus.startServer(9991, "/metrics"); +PrometheusMeterRegistry prometheusRegistry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); +PrometheusMetricsCollector prometheus = new PrometheusMetricsCollector(prometheusRegistry); +// Expose prometheusRegistry.scrape() from an endpoint your application owns. DatadogMetricsCollector datadog = new DatadogMetricsCollector( System.getenv("DATADOG_API_KEY"), @@ -1293,9 +1309,9 @@ public class ConductorMonitoringSetup { new AnotherTaskWorker() ); - // 3. Setup Prometheus metrics - PrometheusMetricsCollector prometheus = new PrometheusMetricsCollector(); - prometheus.startServer(9991, "/metrics"); + // 3. Setup Prometheus metrics (expose the registry from an endpoint you own) + PrometheusMeterRegistry prometheusRegistry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + PrometheusMetricsCollector prometheus = new PrometheusMetricsCollector(prometheusRegistry); // 4. Setup custom monitoring SLAMonitor slaMonitor = new SLAMonitor( @@ -1443,12 +1459,11 @@ public class EventSystemTest { **Problem**: Prometheus endpoint returns no metrics **Solution**: -1. Verify server is started: - ```java - metricsCollector.startServer(); // Don't forget this! - ``` +1. Verify the registry is exposed. The SDK does not start a server: + - Spring Boot: confirm `spring-boot-starter-actuator` + `micrometer-registry-prometheus` are present and the `prometheus` endpoint is exposed (`/actuator/prometheus` by default). + - Plain Java: confirm you are serving `registry.scrape()` from your own HTTP endpoint, and that the same `registry` was passed to `new PrometheusMetricsCollector(registry)`. -2. Check port availability: +2. Check port availability for whatever port your app exposes, e.g.: ```bash lsof -i :9991 ``` diff --git a/README.md b/README.md index 106833cb6..737efbf53 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,7 @@ executor.initWorkers("com.mycompany.workers"); // Package to scan for @WorkerTa ## Monitoring Workers -Enable Prometheus metrics collection for monitoring workers: +Collect Micrometer/Prometheus metrics for monitoring workers. The SDK publishes into a `MeterRegistry` you provide; it does not start a metrics web server. ```groovy // Using conductor-client-metrics module @@ -307,11 +307,25 @@ dependencies { } ``` +**Spring Boot:** add Actuator and the Prometheus registry, and SDK metrics appear on `/actuator/prometheus` automatically (no wiring code needed): + +```groovy +dependencies { + implementation 'org.conductoross:conductor-client-metrics:5.1.0' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' +} +``` + +**Plain Java:** create a registry, pass it to the collector, and expose it yourself: + ```java import com.netflix.conductor.client.metrics.prometheus.PrometheusMetricsCollector; +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; -PrometheusMetricsCollector metricsCollector = new PrometheusMetricsCollector(); -metricsCollector.startServer(); // http://localhost:9991/metrics +PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); +PrometheusMetricsCollector metricsCollector = new PrometheusMetricsCollector(registry); ConductorClient client = ConductorClient.builder() .basePath("...") @@ -324,6 +338,8 @@ WorkflowClient workflowClient = new WorkflowClient(client); TaskRunnerConfigurer configurer = new TaskRunnerConfigurer.Builder(taskClient, workers) .withThreadCount(10) .build(); + +// Expose registry.scrape() from an HTTP endpoint your application owns. ``` When a `MetricsCollector` is attached to the `ConductorClient`, downstream clients (`TaskClient`, `WorkflowClient`, `TaskRunnerConfigurer`) automatically register themselves as event listeners. diff --git a/conductor-client-metrics/README.md b/conductor-client-metrics/README.md index 9ef6e767a..463f7eacf 100644 --- a/conductor-client-metrics/README.md +++ b/conductor-client-metrics/README.md @@ -1,6 +1,13 @@ # Conductor Client Metrics -The `conductor-client-metrics` module provides Prometheus metrics for Java SDK clients and workers. It helps operators monitor worker polling, task execution, task result updates, payload sizes, workflow starts, and HTTP client latency. +The `conductor-client-metrics` module records Micrometer metrics for Java SDK clients and workers. It helps operators monitor worker polling, task execution, task result updates, payload sizes, workflow starts, and HTTP client latency. + +The SDK **should not start a metrics web server**. Metrics are published into a Micrometer `MeterRegistry` that you supply, and exposing them for scraping is the responsibility of the embedding application: + +- **Spring Boot apps** hand in the application's `MeterRegistry` (the one backing `/actuator/prometheus`), so SDK metrics appear alongside the app's own metrics. This module ships a Spring Boot auto-configuration that does this automatically. +- **Plain Java apps** create a `PrometheusMeterRegistry` (or any other registry), pass it to the collector, and expose it however they like (e.g. serve `registry.scrape()` from an HTTP endpoint they own). + +> **Deprecated:** the no-argument `new PrometheusMetricsCollector()` plus `startServer()` still spin up a minimal embedded scrape server for backward compatibility, but both are deprecated and will be removed in a future major release. Prefer supplying a `MeterRegistry`. ## Installation @@ -14,13 +21,48 @@ dependencies { ## Usage -Create a `PrometheusMetricsCollector`, start the scrape server, and pass the collector to `ConductorClient.Builder`. All downstream clients and the task runner auto-register themselves as listeners. +### Spring Boot (recommended) + +Add this module and Spring Boot Actuator with the Prometheus registry to a Spring Boot worker app: + +```groovy +dependencies { + implementation 'org.conductoross:conductor-client-metrics:5.1.0' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' +} +``` + +That's it. When a `MeterRegistry` bean is present, `ConductorMetricsAutoConfiguration` creates a `MetricsCollector` bound to it, and the Conductor Spring auto-configuration wires that collector into the client and task runner. SDK metrics then show up on the app's existing scrape endpoint (by default `/actuator/prometheus`). + +To serve them at a custom path/port, use the standard Actuator properties, e.g.: + +```yaml +server: + port: 9991 +management: + endpoints: + web: + base-path: / + exposure: + include: health,prometheus + path-mapping: + prometheus: metrics # scrape endpoint at /metrics +``` + +To provide your own collector (or a non-Prometheus registry), just declare a `MetricsCollector` bean; the auto-configuration backs off (`@ConditionalOnMissingBean`). + +### Plain Java + +Create a registry, pass it to the collector, and expose the registry yourself. Passing the collector to `ConductorClient.Builder` auto-registers all downstream clients and the task runner as listeners. ```java import com.netflix.conductor.client.metrics.prometheus.PrometheusMetricsCollector; +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; -PrometheusMetricsCollector metricsCollector = new PrometheusMetricsCollector(); -metricsCollector.startServer(); // http://localhost:9991/metrics +PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); +PrometheusMetricsCollector metricsCollector = new PrometheusMetricsCollector(registry); ConductorClient client = ConductorClient.builder() .basePath("http://conductor-server:8080/api") @@ -34,9 +76,18 @@ TaskRunnerConfigurer configurer = new TaskRunnerConfigurer.Builder(taskClient, w .withThreadCount(10) .build(); configurer.init(); + +// Expose the metrics from an HTTP endpoint you own, e.g.: +// var server = com.sun.net.httpserver.HttpServer.create(new InetSocketAddress(9991), 0); +// server.createContext("/metrics", exchange -> { +// byte[] body = registry.scrape().getBytes(); +// exchange.sendResponseHeaders(200, body.length); +// try (var os = exchange.getResponseBody()) { os.write(body); } +// }); +// server.start(); ``` -`startServer()` also accepts `(port, endpoint)` for custom scrape configurations. The client builder accepts the usual timeouts, SSL, authentication, and other options alongside `withMetricsCollector` -- none of them change how metrics wiring works. +The client builder accepts the usual timeouts, SSL, authentication, and other options alongside `withMetricsCollector` -- none of them change how metrics wiring works. ### How Auto-Registration Works @@ -57,9 +108,11 @@ For advanced use cases where you need fine-grained control over which listeners ```java import com.netflix.conductor.client.metrics.prometheus.PrometheusMetricsCollector; +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; -PrometheusMetricsCollector metricsCollector = new PrometheusMetricsCollector(); -metricsCollector.startServer(); // http://localhost:9991/metrics +PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); +PrometheusMetricsCollector metricsCollector = new PrometheusMetricsCollector(registry); ConductorClient client = ConductorClient.builder() .basePath("http://conductor-server:8080/api") @@ -168,9 +221,9 @@ Micrometer publishes a `*_max` Gauge alongside every Timer and DistributionSumma ### Metrics Are Empty -- Verify that the collector is wired into the client. The simplest check: was `withMetricsCollector` called on `ConductorClient.Builder`? +- Verify that the collector is wired into the client. In Spring Boot, confirm a `MeterRegistry` bean exists (add `spring-boot-starter-actuator` + `micrometer-registry-prometheus`). In plain Java, confirm `withMetricsCollector` was called on `ConductorClient.Builder`. - Verify workers have polled or executed tasks. Metrics are created lazily when the relevant event occurs. -- Confirm the scrape endpoint is reachable at the expected host and port. +- Confirm the scrape endpoint is reachable at the expected host and port (Actuator's `/actuator/prometheus` by default, or whatever your app exposes). ### Missing HTTP or Size Metrics diff --git a/conductor-client-metrics/build.gradle b/conductor-client-metrics/build.gradle index 9a4e2e3e0..940f426c0 100644 --- a/conductor-client-metrics/build.gradle +++ b/conductor-client-metrics/build.gradle @@ -17,9 +17,20 @@ dependencies { implementation project(":conductor-client") implementation "com.squareup.okhttp3:okhttp:${versions.okHttp}" + // Optional Spring Boot auto-configuration. Present only at compile time so + // plain-Java consumers pull in no Spring dependency; the auto-config only + // activates when a Spring Boot app already has spring-boot-autoconfigure + // (and a Micrometer MeterRegistry bean) on its runtime classpath. + compileOnly 'org.springframework.boot:spring-boot-autoconfigure:3.5.13' + testImplementation 'org.mockito:mockito-core:5.12.0' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.3' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.3' + + // Exercises the Spring Boot auto-configuration in isolation. + testImplementation 'org.springframework.boot:spring-boot-autoconfigure:3.5.13' + testImplementation 'org.springframework.boot:spring-boot-test:3.5.13' + testImplementation 'org.assertj:assertj-core:3.25.3' } java { diff --git a/conductor-client-metrics/src/main/java/com/netflix/conductor/client/metrics/prometheus/ConductorMetricsAutoConfiguration.java b/conductor-client-metrics/src/main/java/com/netflix/conductor/client/metrics/prometheus/ConductorMetricsAutoConfiguration.java new file mode 100644 index 000000000..d1ad0994d --- /dev/null +++ b/conductor-client-metrics/src/main/java/com/netflix/conductor/client/metrics/prometheus/ConductorMetricsAutoConfiguration.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Conductor Authors. + *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *
+ * http://www.apache.org/licenses/LICENSE-2.0 + *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package com.netflix.conductor.client.metrics.prometheus; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +import com.netflix.conductor.client.metrics.MetricsCollector; + +import io.micrometer.core.instrument.MeterRegistry; + +/** + * Spring Boot auto-configuration that exposes the SDK's {@link MetricsCollector} + * bound to the application's Micrometer {@link MeterRegistry}. + * + *
When a {@code MeterRegistry} bean exists (e.g. the one Spring Boot Actuator + * creates to back {@code /actuator/prometheus}), the SDK's metrics are published + * into it and therefore show up alongside the application's own metrics — + * no separate metrics web server is started by the SDK. + * + *
This class is only loaded in a Spring Boot application (it is referenced + * from {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}). + * Plain-Java consumers never trigger it and pull in no Spring dependency. + */ +@AutoConfiguration( + afterName = { + "org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration", + "org.springframework.boot.actuate.autoconfigure.metrics.export.prometheus.PrometheusMetricsExportAutoConfiguration" + }) +@ConditionalOnClass(MeterRegistry.class) +public class ConductorMetricsAutoConfiguration { + + @Bean + @ConditionalOnBean(MeterRegistry.class) + @ConditionalOnMissingBean(MetricsCollector.class) + public MetricsCollector conductorMetricsCollector(MeterRegistry meterRegistry) { + return new PrometheusMetricsCollector(meterRegistry); + } +} diff --git a/conductor-client-metrics/src/main/java/com/netflix/conductor/client/metrics/prometheus/PrometheusApiClientMetrics.java b/conductor-client-metrics/src/main/java/com/netflix/conductor/client/metrics/prometheus/PrometheusApiClientMetrics.java index 7ebd403ec..252579ea2 100644 --- a/conductor-client-metrics/src/main/java/com/netflix/conductor/client/metrics/prometheus/PrometheusApiClientMetrics.java +++ b/conductor-client-metrics/src/main/java/com/netflix/conductor/client/metrics/prometheus/PrometheusApiClientMetrics.java @@ -17,8 +17,8 @@ import com.netflix.conductor.client.metrics.ApiClientMetrics; import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; -import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; /** * Prometheus-backed implementation of {@link ApiClientMetrics} that emits the @@ -47,9 +47,9 @@ public final class PrometheusApiClientMetrics implements ApiClientMetrics { 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000 }; - private final PrometheusMeterRegistry registry; + private final MeterRegistry registry; - public PrometheusApiClientMetrics(PrometheusMeterRegistry registry) { + public PrometheusApiClientMetrics(MeterRegistry registry) { this.registry = registry; } diff --git a/conductor-client-metrics/src/main/java/com/netflix/conductor/client/metrics/prometheus/PrometheusMetricsCollector.java b/conductor-client-metrics/src/main/java/com/netflix/conductor/client/metrics/prometheus/PrometheusMetricsCollector.java index 1d99e6df1..4a84f8d59 100644 --- a/conductor-client-metrics/src/main/java/com/netflix/conductor/client/metrics/prometheus/PrometheusMetricsCollector.java +++ b/conductor-client-metrics/src/main/java/com/netflix/conductor/client/metrics/prometheus/PrometheusMetricsCollector.java @@ -47,13 +47,36 @@ import com.sun.net.httpserver.HttpServer; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import io.micrometer.prometheusmetrics.PrometheusConfig; import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; /** - * Prometheus metrics implementation emitting the harmonized metric names - * defined in the cross-SDK metrics catalog. + * Micrometer-backed {@link MetricsCollector} that records the harmonized metric + * names defined in the cross-SDK metrics catalog into a caller-supplied + * {@link MeterRegistry}. + * + *
The recommended usage is to not let the SDK start any HTTP server and + * instead let the embedding application expose the metrics: + * + *
For backward compatibility, the no-argument constructor plus
+ * {@link #startServer()} still spin up a minimal embedded scrape server, but
+ * both are deprecated — a library should not run a web server. Prefer the
+ * {@link #PrometheusMetricsCollector(MeterRegistry)} constructor.
*/
public class PrometheusMetricsCollector implements MetricsCollector {
@@ -85,10 +108,33 @@ public class PrometheusMetricsCollector implements MetricsCollector {
private static final String STATUS_SUCCESS = "SUCCESS";
private static final String STATUS_FAILURE = "FAILURE";
- private final PrometheusMeterRegistry registry;
+ private final MeterRegistry registry;
private final PrometheusApiClientMetrics apiClientMetrics;
private final ConcurrentHashMap
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+package com.netflix.conductor.client.metrics.prometheus;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import com.netflix.conductor.client.metrics.MetricsCollector;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ConductorMetricsAutoConfigurationTest {
+
+ private final ApplicationContextRunner runner = new ApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(ConductorMetricsAutoConfiguration.class));
+
+ @Test
+ void registersCollectorBoundToApplicationRegistry() {
+ SimpleMeterRegistry registry = new SimpleMeterRegistry();
+ runner.withBean(MeterRegistry.class, () -> registry).run(ctx -> {
+ assertTrue(ctx.containsBean("conductorMetricsCollector"));
+ MetricsCollector collector = ctx.getBean(MetricsCollector.class);
+ assertSame(registry, ((PrometheusMetricsCollector) collector).getRegistry());
+ });
+ }
+
+ @Test
+ void doesNotRegisterCollectorWithoutMeterRegistry() {
+ runner.run(ctx -> assertFalse(ctx.containsBean("conductorMetricsCollector")));
+ }
+
+ @Test
+ void backsOffWhenApplicationSuppliesItsOwnCollector() {
+ runner.withBean(MeterRegistry.class, SimpleMeterRegistry::new)
+ .withBean("customCollector", MetricsCollector.class,
+ () -> new PrometheusMetricsCollector(new SimpleMeterRegistry()))
+ .run(ctx -> {
+ assertTrue(ctx.containsBean("customCollector"));
+ assertFalse(ctx.containsBean("conductorMetricsCollector"));
+ });
+ }
+}
diff --git a/conductor-client-metrics/src/test/java/com/netflix/conductor/client/metrics/prometheus/PrometheusMetricsCollectorTest.java b/conductor-client-metrics/src/test/java/com/netflix/conductor/client/metrics/prometheus/PrometheusMetricsCollectorTest.java
index c39c7c268..07b3c7d75 100644
--- a/conductor-client-metrics/src/test/java/com/netflix/conductor/client/metrics/prometheus/PrometheusMetricsCollectorTest.java
+++ b/conductor-client-metrics/src/test/java/com/netflix/conductor/client/metrics/prometheus/PrometheusMetricsCollectorTest.java
@@ -297,6 +297,25 @@ void getRegistryReturnsNonNull() {
assertNotNull(collector.getRegistry());
}
+ // --- Deprecated backward-compatible embedded-server path ---
+
+ @Test
+ @SuppressWarnings("deprecation")
+ void deprecatedNoArgConstructorStillRecordsIntoPrometheusRegistry() {
+ PrometheusMetricsCollector legacy = new PrometheusMetricsCollector();
+ assertInstanceOf(io.micrometer.prometheusmetrics.PrometheusMeterRegistry.class, legacy.getRegistry());
+ assertNotNull(legacy.getApiClientMetrics());
+ assertDoesNotThrow(() -> legacy.consume(new PollStarted("HTTP")));
+ }
+
+ @Test
+ @SuppressWarnings("deprecation")
+ void startServerRejectsNonPrometheusRegistry() {
+ PrometheusMetricsCollector simple =
+ new PrometheusMetricsCollector(new io.micrometer.core.instrument.simple.SimpleMeterRegistry());
+ assertThrows(IllegalStateException.class, () -> simple.startServer(0, "/metrics"));
+ }
+
// --- Multiple increments accumulate ---
@Test
diff --git a/conductor-client-spring-boot4/src/main/java/com/netflix/conductor/client/spring/ConductorClientAutoConfiguration.java b/conductor-client-spring-boot4/src/main/java/com/netflix/conductor/client/spring/ConductorClientAutoConfiguration.java
index 0baf28177..00309c051 100644
--- a/conductor-client-spring-boot4/src/main/java/com/netflix/conductor/client/spring/ConductorClientAutoConfiguration.java
+++ b/conductor-client-spring-boot4/src/main/java/com/netflix/conductor/client/spring/ConductorClientAutoConfiguration.java
@@ -49,7 +49,8 @@ public class ConductorClientAutoConfiguration {
@ConditionalOnMissingBean
@ConditionalOnExpression(
"'${conductor.client.root-uri:}' != '' or '${conductor.client.base-path:}' != ''")
- public ConductorClient conductorClient(ClientProperties properties) {
+ public ConductorClient conductorClient(ClientProperties properties,
+ Optional
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+package io.orkes.conductor.harness;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+
+/**
+ * Spring Boot entry point for the Java SDK worker harness.
+ *
+ * The SDK no longer starts its own metrics web server. Instead, the metrics
+ * are published into the Spring-managed Micrometer registry and exposed through
+ * Spring Boot Actuator. See {@code application.yml} for the endpoint mapping
+ * (Prometheus scrape at {@code /metrics} on the configured port).
+ */
+@SpringBootApplication
+@EnableConfigurationProperties(HarnessProperties.class)
+public class HarnessApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(HarnessApplication.class, args);
+ }
+}
diff --git a/harness/src/main/java/io/orkes/conductor/harness/HarnessBootstrap.java b/harness/src/main/java/io/orkes/conductor/harness/HarnessBootstrap.java
new file mode 100644
index 000000000..cf6aa535b
--- /dev/null
+++ b/harness/src/main/java/io/orkes/conductor/harness/HarnessBootstrap.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2024 Conductor Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+package io.orkes.conductor.harness;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.stereotype.Component;
+
+import com.netflix.conductor.client.http.ConductorClient;
+import com.netflix.conductor.client.http.MetadataClient;
+import com.netflix.conductor.client.http.WorkflowClient;
+import com.netflix.conductor.common.metadata.tasks.TaskDef;
+import com.netflix.conductor.common.metadata.tasks.TaskType;
+import com.netflix.conductor.common.metadata.workflow.WorkflowDef;
+import com.netflix.conductor.common.metadata.workflow.WorkflowTask;
+
+import jakarta.annotation.PreDestroy;
+
+/**
+ * Drives the self-feeding harness once the Spring context (and the
+ * auto-configured, already-polling {@code TaskRunnerConfigurer}) is up:
+ * registers metadata, then starts the workflow governor and the optional
+ * status probe.
+ */
+@Component
+public class HarnessBootstrap implements ApplicationRunner {
+
+ private static final Logger log = LoggerFactory.getLogger(HarnessBootstrap.class);
+
+ private static final String WORKFLOW_NAME = "java_simulated_tasks_workflow";
+
+ private final ConductorClient client;
+ private final WorkflowClient workflowClient;
+ private final HarnessProperties props;
+ private final List
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
- * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations under the License.
- */
-package io.orkes.conductor.harness;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import com.netflix.conductor.client.automator.TaskRunnerConfigurer;
-import com.netflix.conductor.client.http.ConductorClient;
-import com.netflix.conductor.client.http.MetadataClient;
-import com.netflix.conductor.client.http.TaskClient;
-import com.netflix.conductor.client.http.WorkflowClient;
-import com.netflix.conductor.client.metrics.prometheus.PrometheusMetricsCollector;
-import com.netflix.conductor.client.worker.Worker;
-import com.netflix.conductor.common.metadata.tasks.TaskDef;
-import com.netflix.conductor.common.metadata.tasks.TaskType;
-import com.netflix.conductor.common.metadata.workflow.WorkflowDef;
-import com.netflix.conductor.common.metadata.workflow.WorkflowTask;
-
-import io.orkes.conductor.client.ApiClient;
-
-public class HarnessMain {
-
- private static final Logger log = LoggerFactory.getLogger(HarnessMain.class);
-
- private static final String WORKFLOW_NAME = "java_simulated_tasks_workflow";
-
- private static final String[][] SIMULATED_WORKERS = {
- {"java_worker_0", "quickpulse", "1"},
- {"java_worker_1", "whisperlink", "2"},
- {"java_worker_2", "shadowfetch", "3"},
- {"java_worker_3", "ironforge", "4"},
- {"java_worker_4", "deepcrawl", "5"},
- };
-
- public static void main(String[] args) throws Exception {
- boolean workersOnly = Arrays.asList(args).contains("--workers-only");
-
- int workflowsPerSec = envInt("HARNESS_WORKFLOWS_PER_SEC", 2);
- int batchSize = envInt("HARNESS_BATCH_SIZE", 20);
- int pollIntervalMs = envInt("HARNESS_POLL_INTERVAL_MS", 100);
- int metricsPort = envInt("HARNESS_METRICS_PORT", 9991);
- int probeRatePerSec = envInt("HARNESS_PROBE_RATE_PER_SEC", 0);
-
- List
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+package io.orkes.conductor.harness;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * Tuning knobs for the harness, bound from the {@code harness.*} namespace (see
+ * {@code application.yml}, which maps the {@code HARNESS_*} environment variables
+ * onto these properties for backwards compatibility with existing deployments).
+ */
+@Getter
+@Setter
+@ConfigurationProperties("harness")
+public class HarnessProperties {
+
+ /** Workflows to start per second (governor). */
+ private int workflowsPerSec = 2;
+
+ /** Thread count per worker (controls polling concurrency). */
+ private int batchSize = 20;
+
+ /** Milliseconds between poll cycles. */
+ private int pollIntervalMs = 100;
+
+ /** Control-plane probe rate; 0 disables the probe. */
+ private int probeRatePerSec = 0;
+
+ /** When true, run workers only (no metadata registration, governor, or probe). */
+ private boolean workersOnly = false;
+}
diff --git a/harness/src/main/java/io/orkes/conductor/harness/HarnessWorkerConfiguration.java b/harness/src/main/java/io/orkes/conductor/harness/HarnessWorkerConfiguration.java
new file mode 100644
index 000000000..5008ce24c
--- /dev/null
+++ b/harness/src/main/java/io/orkes/conductor/harness/HarnessWorkerConfiguration.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2024 Conductor Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+package io.orkes.conductor.harness;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Registers the five simulated task workers as beans. The auto-configured
+ * {@code TaskRunnerConfigurer} collects every {@code Worker} bean and starts
+ * polling for them. Thread counts are taken from
+ * {@code conductor.worker. The workflow chains the workers in sequence:
+ * quickpulse (1s) → whisperlink (2s) → shadowfetch (3s) →
+ * ironforge (4s) → deepcrawl (5s).
+ */
+@Configuration
+public class HarnessWorkerConfiguration {
+
+ @Bean
+ public SimulatedTaskWorker quickpulseWorker(HarnessProperties props) {
+ return new SimulatedTaskWorker("java_worker_0", "quickpulse", 1, props.getBatchSize(), props.getPollIntervalMs());
+ }
+
+ @Bean
+ public SimulatedTaskWorker whisperlinkWorker(HarnessProperties props) {
+ return new SimulatedTaskWorker("java_worker_1", "whisperlink", 2, props.getBatchSize(), props.getPollIntervalMs());
+ }
+
+ @Bean
+ public SimulatedTaskWorker shadowfetchWorker(HarnessProperties props) {
+ return new SimulatedTaskWorker("java_worker_2", "shadowfetch", 3, props.getBatchSize(), props.getPollIntervalMs());
+ }
+
+ @Bean
+ public SimulatedTaskWorker ironforgeWorker(HarnessProperties props) {
+ return new SimulatedTaskWorker("java_worker_3", "ironforge", 4, props.getBatchSize(), props.getPollIntervalMs());
+ }
+
+ @Bean
+ public SimulatedTaskWorker deepcrawlWorker(HarnessProperties props) {
+ return new SimulatedTaskWorker("java_worker_4", "deepcrawl", 5, props.getBatchSize(), props.getPollIntervalMs());
+ }
+}
diff --git a/harness/src/main/java/io/orkes/conductor/harness/SimulatedTaskWorker.java b/harness/src/main/java/io/orkes/conductor/harness/SimulatedTaskWorker.java
index f633b8f59..818db3a04 100644
--- a/harness/src/main/java/io/orkes/conductor/harness/SimulatedTaskWorker.java
+++ b/harness/src/main/java/io/orkes/conductor/harness/SimulatedTaskWorker.java
@@ -65,6 +65,16 @@ public String getTaskDefName() {
return taskName;
}
+ /** Human-friendly alias, also used as the workflow task reference name. */
+ public String getCodename() {
+ return codename;
+ }
+
+ /** Default simulated delay in seconds (drives the task-def description). */
+ public int getDelaySeconds() {
+ return defaultDelayMs / 1000;
+ }
+
@Override
public String getIdentity() {
return workerId;
diff --git a/harness/src/main/java/io/orkes/conductor/harness/WorkflowStatusProbe.java b/harness/src/main/java/io/orkes/conductor/harness/WorkflowStatusProbe.java
index c663d7841..9ac77909c 100644
--- a/harness/src/main/java/io/orkes/conductor/harness/WorkflowStatusProbe.java
+++ b/harness/src/main/java/io/orkes/conductor/harness/WorkflowStatusProbe.java
@@ -28,7 +28,7 @@
* endpoints, so {@code http_api_client_request_seconds} on the canonical
* Prometheus surface picks up entries with {@code uri=/api/workflow/<uuid>}
* and {@code uri=/api/workflow/<uuid>/status}. The default
- * {@link HarnessMain} traffic only ever hits bounded, no-path-param URLs
+ * {@link HarnessBootstrap} traffic only ever hits bounded, no-path-param URLs
* ({@code /api/tasks/poll/batch/<taskType>}, {@code /api/tasks},
* {@code /api/workflow}, etc.), so the high-cardinality concern on the
* {@code uri} label is invisible without something like this probe.
diff --git a/harness/src/main/resources/application.yml b/harness/src/main/resources/application.yml
new file mode 100644
index 000000000..1b173c528
--- /dev/null
+++ b/harness/src/main/resources/application.yml
@@ -0,0 +1,50 @@
+# The harness listens on a single HTTP port that serves the Actuator
+# endpoints. The Prometheus scrape endpoint is remapped to /metrics so it
+# matches the cross-SDK PodMonitor contract (port name "metrics" -> 9991,
+# path /metrics) without any SDK-owned web server.
+server:
+ port: ${HARNESS_METRICS_PORT:9991}
+
+management:
+ endpoints:
+ web:
+ # Serve endpoints at the root (e.g. /metrics, /health) instead of the
+ # default /actuator/* prefix.
+ base-path: /
+ exposure:
+ include: health,prometheus,metrics
+ path-mapping:
+ # Prometheus text exposition (what Prometheus scrapes) -> /metrics
+ prometheus: metrics
+ # Actuator's JSON metrics browser -> /metrics-json (renamed to avoid
+ # colliding with the Prometheus endpoint above).
+ metrics: metrics-json
+ endpoint:
+ health:
+ probes:
+ enabled: true
+
+conductor:
+ client:
+ base-path: ${CONDUCTOR_SERVER_URL:}
+ key-id: ${CONDUCTOR_AUTH_KEY:}
+ secret: ${CONDUCTOR_AUTH_SECRET:}
+ # Thread count per worker (polling concurrency). Mirrors HARNESS_BATCH_SIZE.
+ worker:
+ java_worker_0:
+ threadCount: ${HARNESS_BATCH_SIZE:20}
+ java_worker_1:
+ threadCount: ${HARNESS_BATCH_SIZE:20}
+ java_worker_2:
+ threadCount: ${HARNESS_BATCH_SIZE:20}
+ java_worker_3:
+ threadCount: ${HARNESS_BATCH_SIZE:20}
+ java_worker_4:
+ threadCount: ${HARNESS_BATCH_SIZE:20}
+
+harness:
+ workflows-per-sec: ${HARNESS_WORKFLOWS_PER_SEC:2}
+ batch-size: ${HARNESS_BATCH_SIZE:20}
+ poll-interval-ms: ${HARNESS_POLL_INTERVAL_MS:100}
+ probe-rate-per-sec: ${HARNESS_PROBE_RATE_PER_SEC:0}
+ workers-only: ${HARNESS_WORKERS_ONLY:false}