diff --git a/contrib/temporal-gcp/README.md b/contrib/temporal-gcp/README.md new file mode 100644 index 0000000000..dbe6ececb3 --- /dev/null +++ b/contrib/temporal-gcp/README.md @@ -0,0 +1,51 @@ +# Temporal Google Cloud module + +This module provides an OpenTelemetry plugin with defaults for Temporal Java SDK workers running on Google Cloud Run services and worker pools. + +> **Collector required by default:** The plugin exports metrics and traces to an OTLP collector at `http://localhost:4317`. It does not export directly to Google Cloud. Deploy the Google-Built OpenTelemetry Collector as a sidecar, configure another collector endpoint, or provide an application-owned `OpenTelemetry` instance. Without a collector at the configured endpoint, telemetry is not delivered to Google Cloud. + +This integration is for container-based Cloud Run workloads. It does not implement a Cloud Run functions invocation lifecycle. + +## Usage + +Add `temporal-gcp` next to your Temporal SDK dependency, then install the plugin on service stubs options before creating clients and workers: + +```java +GcpOpenTelemetryPlugin plugin = GcpOpenTelemetryPlugin.newBuilder().build(); + +WorkflowServiceStubs service = + WorkflowServiceStubs.newServiceStubs( + WorkflowServiceStubsOptions.newBuilder() + .setPlugins(plugin) + .build()); +WorkflowClient client = WorkflowClient.newInstance(service); +WorkerFactory factory = WorkerFactory.newInstance(client); +``` + +The plugin configures the SDK metrics scope, tracing interceptors, OTLP metric and trace exporters, and worker-factory shutdown flushing through `temporal-opentelemetry`. Do not install both `GcpOpenTelemetryPlugin` and `OpenTelemetryPlugin` on the same service stubs. + +The OTLP endpoint is resolved in this order: + +1. `Builder.setEndpoint(...)`. +2. `OTEL_EXPORTER_OTLP_ENDPOINT`. +3. `http://localhost:4317`. + +The OpenTelemetry service name is resolved in this order: + +1. `Builder.setServiceName(...)`. +2. `OTEL_SERVICE_NAME`. +3. `CLOUD_RUN_WORKER_POOL` for a Cloud Run worker pool. +4. `K_SERVICE` for a Cloud Run service. +5. `temporal-worker`. + +The collector should use its GCP resource detector to add the Google Cloud project, location, revision, and monitored-resource attributes. This module does not call the Google Cloud metadata server and adds no Google Cloud client libraries or exporters to the worker process. + +## Collector sidecar + +Google publishes the Google-Built OpenTelemetry Collector as a container image. Configure it as a second Cloud Run container, listen for OTLP gRPC on `localhost:4317`, and use its GCP exporters for metrics and traces. For the image, recommended collector configuration, IAM roles, health check, and Secret Manager mount, see [Deploy Google-Built OpenTelemetry Collector on Cloud Run](https://cloud.google.com/stackdriver/docs/instrumentation/opentelemetry-collector-cloud-run). + +Cloud Run worker pools support sidecar containers over localhost and are intended for continuous background work. The deployment should start the collector before the Temporal worker and use the collector health extension as its startup probe. + +To use an external collector instead, set `OTEL_EXPORTER_OTLP_ENDPOINT` or call `Builder.setEndpoint(...)`. + +To use an application-owned provider, call `Builder.setOpenTelemetry(...)`. In that path, no exporters are created; the plugin installs the Temporal metrics scope, tracing interceptors, and shutdown flush hook around the supplied provider. diff --git a/contrib/temporal-gcp/build.gradle b/contrib/temporal-gcp/build.gradle new file mode 100644 index 0000000000..233176176b --- /dev/null +++ b/contrib/temporal-gcp/build.gradle @@ -0,0 +1,17 @@ +description = '''Temporal Java SDK Google Cloud Support Module''' + +dependencies { + // This module shouldn't carry temporal-sdk with it, especially for situations when users may + // be using a shaded artifact. + compileOnly project(':temporal-serviceclient') + compileOnly project(':temporal-sdk') + compileOnly "javax.annotation:javax.annotation-api:$annotationApiVersion" + + api project(':temporal-opentelemetry') + + testImplementation project(':temporal-sdk') + testImplementation project(':temporal-serviceclient') + testImplementation "junit:junit:${junitVersion}" + + testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}" +} diff --git a/contrib/temporal-gcp/src/main/java/io/temporal/gcp/GcpOpenTelemetryPlugin.java b/contrib/temporal-gcp/src/main/java/io/temporal/gcp/GcpOpenTelemetryPlugin.java new file mode 100644 index 0000000000..8581677c65 --- /dev/null +++ b/contrib/temporal-gcp/src/main/java/io/temporal/gcp/GcpOpenTelemetryPlugin.java @@ -0,0 +1,180 @@ +package io.temporal.gcp; + +import io.opentelemetry.api.OpenTelemetry; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.Experimental; +import io.temporal.common.SimplePlugin; +import io.temporal.opentelemetry.OpenTelemetryPlugin; +import io.temporal.opentelemetry.OpenTelemetryWorker; +import io.temporal.opentelemetry.TimedShutdownHook; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.worker.WorkerFactory; +import io.temporal.worker.WorkerFactoryOptions; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import javax.annotation.Nonnull; + +/** OpenTelemetry plugin with defaults for Temporal workers running on Google Cloud Run. */ +@Experimental +public final class GcpOpenTelemetryPlugin extends SimplePlugin { + public static final String NAME = OpenTelemetryPlugin.NAME; + public static final String OTEL_EXPORTER_OTLP_ENDPOINT = + OpenTelemetryWorker.OTEL_EXPORTER_OTLP_ENDPOINT; + public static final String OTEL_SERVICE_NAME = OpenTelemetryWorker.OTEL_SERVICE_NAME; + public static final String CLOUD_RUN_WORKER_POOL = "CLOUD_RUN_WORKER_POOL"; + public static final String K_SERVICE = "K_SERVICE"; + public static final String DEFAULT_OTLP_ENDPOINT = OpenTelemetryWorker.DEFAULT_OTLP_ENDPOINT; + public static final String DEFAULT_SERVICE_NAME = OpenTelemetryWorker.DEFAULT_SERVICE_NAME; + + private final OpenTelemetryPlugin delegate; + + private GcpOpenTelemetryPlugin(Builder builder) { + super(NAME); + this.delegate = builder.buildDelegate(); + } + + public static Builder newBuilder() { + return new Builder(System.getenv()); + } + + public static Builder newBuilder(@Nonnull Map env) { + return new Builder(env); + } + + public String getEndpoint() { + return delegate.getEndpoint(); + } + + public String getServiceName() { + return delegate.getServiceName(); + } + + public OpenTelemetry getOpenTelemetry() { + return delegate.getOpenTelemetry(); + } + + /** + * Creates a flush hook that reports buffered Temporal metrics before force-flushing OpenTelemetry + * providers. + */ + public TimedShutdownHook newFlushHook() { + return delegate.newFlushHook(); + } + + @Override + public void configureServiceStubs(@Nonnull WorkflowServiceStubsOptions.Builder builder) { + delegate.configureServiceStubs(builder); + } + + @Override + public void configureWorkflowClient(@Nonnull WorkflowClientOptions.Builder builder) { + delegate.configureWorkflowClient(builder); + } + + @Override + public void configureWorkerFactory(@Nonnull WorkerFactoryOptions.Builder builder) { + delegate.configureWorkerFactory(builder); + } + + @Override + public void shutdownWorkerFactory( + @Nonnull WorkerFactory factory, @Nonnull Consumer next) { + delegate.shutdownWorkerFactory(factory, next); + } + + /** Builder for {@link GcpOpenTelemetryPlugin}. */ + public static final class Builder { + private final Map env; + private final OpenTelemetryPlugin.Builder delegate; + private String serviceName; + + private Builder(Map env) { + this.env = Objects.requireNonNull(env, "env"); + this.delegate = OpenTelemetryPlugin.newBuilder(env); + } + + /** + * Uses an application-owned OpenTelemetry instance instead of creating an SDK and exporters. + */ + public Builder setOpenTelemetry(@Nonnull OpenTelemetry openTelemetry) { + delegate.setOpenTelemetry(openTelemetry); + return this; + } + + /** Sets the OTLP metric and trace exporter endpoint used by the default SDK setup. */ + public Builder setEndpoint(@Nonnull String endpoint) { + delegate.setEndpoint(endpoint); + return this; + } + + /** Sets the service name used by the default SDK resource and Temporal metrics reporter. */ + public Builder setServiceName(@Nonnull String serviceName) { + this.serviceName = Objects.requireNonNull(serviceName, "serviceName"); + return this; + } + + /** Sets the interval used by the Temporal metrics scope and periodic metric reader. */ + public Builder setMetricsReportInterval(@Nonnull Duration metricsReportInterval) { + delegate.setMetricsReportInterval(metricsReportInterval); + return this; + } + + /** Sets how long the OpenTelemetry flush hook waits for provider flushing. */ + public Builder setFlushTimeout(@Nonnull Duration flushTimeout) { + delegate.setFlushTimeout(flushTimeout); + return this; + } + + /** Overrides the OpenTelemetry provider flush hook. */ + public Builder setFlushHook(@Nonnull Runnable flushHook) { + delegate.setFlushHook(flushHook); + return this; + } + + public String getEndpoint() { + return delegate.getEndpoint(); + } + + public String getServiceName() { + return serviceName == null ? resolveServiceName(env) : serviceName; + } + + public OpenTelemetry createOpenTelemetry() { + applyServiceNameDefault(); + return delegate.createOpenTelemetry(); + } + + public GcpOpenTelemetryPlugin build() { + return new GcpOpenTelemetryPlugin(this); + } + + private OpenTelemetryPlugin buildDelegate() { + applyServiceNameDefault(); + return delegate.build(); + } + + private void applyServiceNameDefault() { + delegate.setServiceName(getServiceName()); + } + } + + static String resolveServiceName(Map env) { + String value = nonEmptyEnv(env, OTEL_SERVICE_NAME); + if (value != null) { + return value; + } + value = nonEmptyEnv(env, CLOUD_RUN_WORKER_POOL); + if (value != null) { + return value; + } + value = nonEmptyEnv(env, K_SERVICE); + return value == null ? DEFAULT_SERVICE_NAME : value; + } + + private static String nonEmptyEnv(Map env, String name) { + String value = env.get(name); + return value == null || value.trim().isEmpty() ? null : value; + } +} diff --git a/contrib/temporal-gcp/src/test/java/io/temporal/gcp/GcpOpenTelemetryPluginTest.java b/contrib/temporal-gcp/src/test/java/io/temporal/gcp/GcpOpenTelemetryPluginTest.java new file mode 100644 index 0000000000..f523249026 --- /dev/null +++ b/contrib/temporal-gcp/src/test/java/io/temporal/gcp/GcpOpenTelemetryPluginTest.java @@ -0,0 +1,99 @@ +package io.temporal.gcp; + +import static org.junit.Assert.*; + +import io.opentelemetry.api.OpenTelemetry; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.opentelemetry.OpenTelemetryPlugin; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.worker.WorkerFactoryOptions; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; + +public class GcpOpenTelemetryPluginTest { + @Test + public void defaultsToLocalCollectorAndGenericServiceName() { + GcpOpenTelemetryPlugin.Builder builder = GcpOpenTelemetryPlugin.newBuilder(new HashMap<>()); + + assertEquals("http://localhost:4317", builder.getEndpoint()); + assertEquals("temporal-worker", builder.getServiceName()); + } + + @Test + public void resolvesCloudRunServiceNameWithExpectedPrecedence() { + Map env = new HashMap<>(); + env.put(GcpOpenTelemetryPlugin.K_SERVICE, "cloud-run-service"); + assertEquals("cloud-run-service", GcpOpenTelemetryPlugin.newBuilder(env).getServiceName()); + + env.put(GcpOpenTelemetryPlugin.CLOUD_RUN_WORKER_POOL, "worker-pool"); + assertEquals("worker-pool", GcpOpenTelemetryPlugin.newBuilder(env).getServiceName()); + + env.put(GcpOpenTelemetryPlugin.OTEL_SERVICE_NAME, "otel-service"); + assertEquals("otel-service", GcpOpenTelemetryPlugin.newBuilder(env).getServiceName()); + + assertEquals( + "builder-service", + GcpOpenTelemetryPlugin.newBuilder(env).setServiceName("builder-service").getServiceName()); + } + + @Test + public void ignoresEmptyEnvironmentValues() { + Map env = new HashMap<>(); + env.put(GcpOpenTelemetryPlugin.OTEL_SERVICE_NAME, " "); + env.put(GcpOpenTelemetryPlugin.CLOUD_RUN_WORKER_POOL, ""); + env.put(GcpOpenTelemetryPlugin.K_SERVICE, "cloud-run-service"); + + assertEquals("cloud-run-service", GcpOpenTelemetryPlugin.newBuilder(env).getServiceName()); + } + + @Test + public void buildAppliesResolvedEndpointAndServiceName() { + Map env = new HashMap<>(); + env.put(GcpOpenTelemetryPlugin.OTEL_EXPORTER_OTLP_ENDPOINT, "http://collector:4317"); + env.put(GcpOpenTelemetryPlugin.CLOUD_RUN_WORKER_POOL, "worker-pool"); + + GcpOpenTelemetryPlugin plugin = + GcpOpenTelemetryPlugin.newBuilder(env).setOpenTelemetry(OpenTelemetry.noop()).build(); + + assertEquals("http://collector:4317", plugin.getEndpoint()); + assertEquals("worker-pool", plugin.getServiceName()); + } + + @Test + public void installsMetricsScopeAndTracingInterceptors() { + GcpOpenTelemetryPlugin plugin = + GcpOpenTelemetryPlugin.newBuilder(new HashMap<>()) + .setOpenTelemetry(OpenTelemetry.noop()) + .build(); + WorkflowServiceStubsOptions.Builder serviceOptions = WorkflowServiceStubsOptions.newBuilder(); + WorkflowClientOptions.Builder clientOptions = WorkflowClientOptions.newBuilder(); + WorkerFactoryOptions.Builder factoryOptions = WorkerFactoryOptions.newBuilder(); + + plugin.configureServiceStubs(serviceOptions); + plugin.configureWorkflowClient(clientOptions); + plugin.configureWorkerFactory(factoryOptions); + + assertEquals(OpenTelemetryPlugin.NAME, plugin.getName()); + assertNotNull(serviceOptions.build().getMetricsScope()); + assertEquals(1, clientOptions.build().getInterceptors().length); + assertEquals(1, factoryOptions.build().getWorkerInterceptors().length); + } + + @Test + public void workerFactoryShutdownFlushesByDefault() { + AtomicInteger flushes = new AtomicInteger(); + AtomicInteger shutdowns = new AtomicInteger(); + GcpOpenTelemetryPlugin plugin = + GcpOpenTelemetryPlugin.newBuilder(new HashMap<>()) + .setOpenTelemetry(OpenTelemetry.noop()) + .setFlushHook(flushes::incrementAndGet) + .build(); + + plugin.shutdownWorkerFactory(null, factory -> shutdowns.incrementAndGet()); + + assertEquals(1, shutdowns.get()); + assertEquals(1, flushes.get()); + } +} diff --git a/settings.gradle b/settings.gradle index 3699ff1508..728ccd58f0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,6 +8,8 @@ include 'temporal-opentracing' project(':temporal-opentracing').projectDir = file('contrib/temporal-opentracing') include 'temporal-opentelemetry' project(':temporal-opentelemetry').projectDir = file('contrib/temporal-opentelemetry') +include 'temporal-gcp' +project(':temporal-gcp').projectDir = file('contrib/temporal-gcp') include 'temporal-kotlin' include 'temporal-spring-ai' project(':temporal-spring-ai').projectDir = file('contrib/temporal-spring-ai') diff --git a/temporal-bom/build.gradle b/temporal-bom/build.gradle index 031633473e..56f7de517d 100644 --- a/temporal-bom/build.gradle +++ b/temporal-bom/build.gradle @@ -7,6 +7,7 @@ description = '''Temporal Java BOM''' dependencies { constraints { api project(':temporal-kotlin') + api project(':temporal-gcp') api project(':temporal-opentelemetry') api project(':temporal-opentracing') api project(':temporal-aws-lambda')