Skip to content

Commit afee30c

Browse files
committed
Add GCP OpenTelemetry Cloud Run worker sample
1 parent 08eae4d commit afee30c

14 files changed

Lines changed: 637 additions & 0 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Temporal using the [Java SDK](https://github.com/temporalio/sdk-java).
55

66
It contains the following modules:
77
* [Core](/core): showcases many different SDK features.
8+
* [Google Cloud OpenTelemetry](/gcp-opentelemetry): runs a continuously polling worker in a Cloud Run worker pool and exports metrics and traces through the Google-Built OpenTelemetry Collector.
89
* [SpringBoot](/springboot): showcases SpringBoot autoconfig integration.
910
* [SpringBoot Basic](/springboot-basic): Minimal sample showing SpringBoot autoconfig integration without any extra external dependencies.
1011
* [Spring AI](/springai): demonstrates the Temporal Spring AI integration — durable AI agents with chat models, tools, MCP servers, vector stores, and embeddings.

gcp-opentelemetry/Dockerfile

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
FROM eclipse-temurin:17-jdk-jammy AS build
2+
3+
WORKDIR /workspace
4+
COPY . .
5+
6+
# Replace this snapshot with the first released version that contains temporal-gcp.
7+
ARG TEMPORAL_GCP_VERSION=1.37.0-SNAPSHOT
8+
RUN ./gradlew --no-daemon :gcp-opentelemetry:installDist \
9+
-PtemporalGcpVersion=${TEMPORAL_GCP_VERSION}
10+
11+
FROM eclipse-temurin:17-jre-jammy
12+
13+
RUN useradd --create-home --uid 10001 temporal
14+
WORKDIR /app
15+
COPY --from=build --chown=temporal:temporal \
16+
/workspace/gcp-opentelemetry/build/install/gcp-opentelemetry/ /app/
17+
18+
USER 10001
19+
ENTRYPOINT ["/app/bin/gcp-opentelemetry"]

gcp-opentelemetry/README.md

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
# Temporal Google Cloud OpenTelemetry worker
2+
3+
This sample runs a continuously polling Temporal worker in a **Cloud Run worker pool** and
4+
exports Temporal SDK metrics and traces through a
5+
[Google-Built OpenTelemetry Collector](https://cloud.google.com/stackdriver/docs/instrumentation/opentelemetry-collector-cloud-run)
6+
sidecar.
7+
8+
It is intentionally not a Cloud Run function or request-driven Cloud Run service. Worker pools
9+
keep CPU allocated while the Temporal worker performs continuous background polling.
10+
11+
```text
12+
Temporal worker
13+
│ OTLP/gRPC, localhost:4317
14+
15+
Google-Built OpenTelemetry Collector sidecar
16+
├── metrics ──► Google Managed Service for Prometheus
17+
└── traces ──► Telemetry (OTLP) API ──► Cloud Trace storage
18+
```
19+
20+
The Java process uses `GcpOpenTelemetryPlugin`, which configures the Temporal SDK metrics scope,
21+
tracing interceptors, OTLP exporters, and shutdown flushing. The plugin defaults to
22+
`http://localhost:4317` and derives `service.name` from the Cloud Run-provided
23+
`CLOUD_RUN_WORKER_POOL` environment variable. It reports and exports metrics every 60 seconds by
24+
default, matching the upstream OpenTelemetry SDK default and the coordinated Temporal GCP plugin
25+
default across Java, Go, and Python. This sample deliberately does not override that interval.
26+
27+
## Unreleased SDK dependency
28+
29+
At the time this sample was added, `io.temporal:temporal-gcp` had not been released. The Gradle
30+
build and Dockerfile therefore use `1.37.0-SNAPSHOT` as an explicit placeholder. A normal build
31+
from Maven Central and the production Docker build remain blocked until a version containing the
32+
module is published. Do not replace the plugin with hand-written OpenTelemetry configuration; that
33+
would stop this sample from exercising the supported SDK API.
34+
35+
You can compile and test against an unmodified local `sdk-java` checkout with Gradle composite
36+
substitution. The sample-level plugin-default test requires the coordinated 60-second SDK change,
37+
so an older checkout fails instead of silently testing a different cadence:
38+
39+
```bash
40+
./gradlew \
41+
-PtemporalSdkPath=/path/to/sdk-java \
42+
:gcp-opentelemetry:test \
43+
:gcp-opentelemetry:installDist
44+
```
45+
46+
Alternatively, after publishing all required `1.37.0-SNAPSHOT` SDK modules to Maven Local, add
47+
`-PuseMavenLocal=true`. Once `temporal-gcp` is released, update the default
48+
`temporalGcpVersion` in `build.gradle` and `TEMPORAL_GCP_VERSION` in `Dockerfile` to that released
49+
version.
50+
51+
## Files
52+
53+
- `src/main/java/io/temporal/samples/gcp/GcpOpenTelemetryWorker.java` creates the plugin, client,
54+
and long-lived worker and performs a bounded shutdown on `SIGTERM`.
55+
- `collector-config.yaml` adapts Google's Cloud Run collector configuration for cumulative
56+
Prometheus metrics and batched traces.
57+
- `worker-pool.yaml` deploys the worker and collector as two containers sharing localhost and
58+
injects the collector configuration from Secret Manager.
59+
- `Dockerfile` packages the Gradle application as the worker container.
60+
61+
## Metric cadence and collector batching
62+
63+
`GcpOpenTelemetryPlugin` defaults to a 60-second metric reporting and export interval. Applications
64+
can still override it with `Builder.setMetricsReportInterval(...)`; custom intervals must remain
65+
above Google Cloud's five-second minimum. When an application supplies its own `OpenTelemetry`
66+
instance, it must configure that instance's metric-reader cadence separately.
67+
68+
Metric cadence and collector batching solve different problems. This collector does **not** put its
69+
cumulative OTLP metrics through a batch processor: a forced shutdown flush can arrive immediately
70+
after a periodic export, and a metric batch could combine both points for the same Prometheus time
71+
series even when the periodic interval is much longer than the batch timeout. Managed Service for
72+
Prometheus rejects that request as `Duplicate TimeSeries`.
73+
74+
The five-second ingestion minimum and metric batching are separate constraints. Meeting the
75+
minimum does not make batching cumulative metrics safe during shutdown.
76+
77+
The dedicated `batch/traces` processor retains a five-second timeout because trace batching is
78+
independently useful and does not have the cumulative-series collision behavior. Do not add
79+
`batch/traces` to `metrics/otlp` or introduce another metric batch processor as a substitute for
80+
choosing an application metric cadence.
81+
82+
## Required Google Cloud APIs
83+
84+
Enable these APIs in the project that hosts the worker pool:
85+
86+
```bash
87+
gcloud services enable \
88+
run.googleapis.com \
89+
artifactregistry.googleapis.com \
90+
secretmanager.googleapis.com \
91+
iam.googleapis.com \
92+
cloudresourcemanager.googleapis.com \
93+
monitoring.googleapis.com \
94+
telemetry.googleapis.com \
95+
cloudtrace.googleapis.com \
96+
--project="$PROJECT_ID"
97+
```
98+
99+
`monitoring.googleapis.com` is required for Google Managed Service for Prometheus ingestion.
100+
Traces are sent using authenticated OTLP to `telemetry.googleapis.com`; the Cloud Trace API must
101+
also be enabled or Google Cloud discards trace data received by the Telemetry API.
102+
The IAM API is used to create the runtime service account. The declarative worker-pool replacement
103+
workflow can require the Cloud Resource Manager API to resolve the target project.
104+
105+
The account enabling APIs needs `roles/serviceusage.serviceUsageAdmin` (or equivalent
106+
permissions).
107+
108+
## IAM
109+
110+
Use a user-managed service account as the Cloud Run worker pool service identity. The collector
111+
uses that identity through Application Default Credentials; do not set
112+
`GOOGLE_APPLICATION_CREDENTIALS` in Cloud Run.
113+
114+
Grant the worker-pool service account:
115+
116+
- `roles/monitoring.metricWriter` on the telemetry project, for the
117+
`googlemanagedprometheus` exporter.
118+
- `roles/telemetry.tracesWriter` on the telemetry project, for OTLP traces sent to the Telemetry
119+
API. `roles/cloudtrace.agent` also contains the write permission, but the narrower Telemetry role
120+
is preferred here.
121+
- `roles/serviceusage.serviceUsageConsumer` on the quota project (the same project in this
122+
example).
123+
- `roles/secretmanager.secretAccessor` on the collector-config and Temporal API-key secrets.
124+
125+
For example:
126+
127+
```bash
128+
gcloud iam service-accounts create temporal-gcp-worker --project="$PROJECT_ID"
129+
130+
SERVICE_ACCOUNT="temporal-gcp-worker@${PROJECT_ID}.iam.gserviceaccount.com"
131+
for ROLE in \
132+
roles/monitoring.metricWriter \
133+
roles/telemetry.tracesWriter \
134+
roles/serviceusage.serviceUsageConsumer \
135+
roles/secretmanager.secretAccessor
136+
do
137+
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
138+
--member="serviceAccount:${SERVICE_ACCOUNT}" \
139+
--role="$ROLE"
140+
done
141+
```
142+
143+
The deployer needs `roles/run.admin` (or the documented worker-pool deployment permissions) and
144+
`roles/iam.serviceAccountUser` on this service account. Creating the service account, Artifact
145+
Registry repository, secrets, and their IAM bindings also requires the corresponding administrative
146+
permissions. The runtime service account does not need those administrative roles.
147+
148+
The collector configuration does not export OTLP logs, so it does not require
149+
`roles/logging.logWriter`. Cloud Run still captures the worker and collector containers' stdout and
150+
stderr through its platform logging.
151+
152+
## Build and deploy
153+
154+
The commands below assume an existing Temporal Cloud namespace and API key.
155+
156+
1. Create the secrets. Pin the API key to a numbered version in `worker-pool.yaml`; the collector
157+
configuration is also pinned to a numbered version and injected as `OTELCOL_CONFIG`.
158+
159+
```bash
160+
printf '%s' "$TEMPORAL_API_KEY" | \
161+
gcloud secrets create temporal-api-key --data-file=- --project="$PROJECT_ID"
162+
163+
gcloud secrets create temporal-gcp-otel-config \
164+
--data-file=gcp-opentelemetry/collector-config.yaml \
165+
--project="$PROJECT_ID"
166+
```
167+
168+
If either secret already exists, add a version with `gcloud secrets versions add` instead.
169+
The manifest loads the collector YAML with `--config=env:OTELCOL_CONFIG`. This is intentional:
170+
secret-backed file volumes have been rejected by some Cloud Run worker-pool rollouts even where
171+
current documentation advertises support.
172+
173+
2. After `temporal-gcp` is released, create an Artifact Registry repository and build and push the
174+
worker image from the repository root:
175+
176+
```bash
177+
REGION=us-central1
178+
RELEASED_TEMPORAL_GCP_VERSION=REPLACE_AFTER_RELEASE
179+
IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/temporal-samples/gcp-opentelemetry:latest"
180+
181+
gcloud artifacts repositories create temporal-samples \
182+
--repository-format=docker \
183+
--location="$REGION" \
184+
--project="$PROJECT_ID"
185+
gcloud auth configure-docker "${REGION}-docker.pkg.dev"
186+
187+
docker build \
188+
-f gcp-opentelemetry/Dockerfile \
189+
--build-arg "TEMPORAL_GCP_VERSION=${RELEASED_TEMPORAL_GCP_VERSION}" \
190+
-t "$IMAGE" \
191+
.
192+
docker push "$IMAGE"
193+
```
194+
195+
3. Edit the placeholders in `worker-pool.yaml`:
196+
197+
- `PROJECT_ID` and `REGION`.
198+
- `NAMESPACE_ID.ACCOUNT_ID` and the matching Temporal Cloud address.
199+
- The Temporal API-key and collector-config secret versions if either is not version `1`.
200+
- Image tag, task queue, instance count, and container resources as appropriate.
201+
202+
4. Deploy the worker pool:
203+
204+
```bash
205+
gcloud run worker-pools replace gcp-opentelemetry/worker-pool.yaml \
206+
--dry-run \
207+
--project="$PROJECT_ID"
208+
209+
gcloud run worker-pools replace gcp-opentelemetry/worker-pool.yaml \
210+
--project="$PROJECT_ID"
211+
```
212+
213+
Worker pools use manual instance counts. This manifest starts one continuously allocated instance;
214+
setting the count to zero disables the worker pool.
215+
216+
## Collector startup and health requirements
217+
218+
The collector is a required dependency, not an optional observability add-on:
219+
220+
- `run.googleapis.com/container-dependencies` declares that `worker` depends on `collector`.
221+
- The collector enables `health_check` on `0.0.0.0:13133` and has a startup probe on `/`.
222+
Cloud Run worker pools do not supply a default startup probe. Without this probe Cloud Run can
223+
start the worker even when the collector failed to load its configuration.
224+
- The collector configuration is a Secret Manager-backed environment variable loaded through the
225+
collector's `env` configuration provider. Keep the YAML below the Cloud Run secret-environment
226+
size limit; this sample configuration is intentionally small.
227+
- The worker starts only after the collector startup probe succeeds. A liveness probe restarts the
228+
collector if it later becomes unhealthy.
229+
- The OTLP receiver listens on `localhost:4317`, which is reachable by both containers because
230+
containers in a worker-pool instance share a network namespace.
231+
- A successful health probe confirms that the collector is running and accepted its configuration;
232+
it does not prove that Google Cloud ingestion and IAM are working. Check collector logs for
233+
exporter errors and verify both signals after deployment.
234+
235+
If the collector is unavailable after startup, the Temporal worker continues processing work but
236+
telemetry delivery can be delayed or lost. Treat collector liveness and exporter failures as
237+
operational alerts.
238+
239+
## Generate and view telemetry
240+
241+
Start `GreetingWorkflow` on task queue `gcp-opentelemetry` with a single string argument. For
242+
example, with an already configured Temporal CLI:
243+
244+
```bash
245+
temporal workflow start \
246+
--workflow-id gcp-otel-greeting \
247+
--type GreetingWorkflow \
248+
--task-queue gcp-opentelemetry \
249+
--input '"Google Cloud"'
250+
```
251+
252+
Temporal SDK metrics appear as Prometheus metrics in Cloud Monitoring. Traces appear in Trace
253+
Explorer after passing through the Telemetry API. The OpenTelemetry service name defaults to the
254+
value of `CLOUD_RUN_WORKER_POOL`; set `OTEL_SERVICE_NAME` on the worker container only if you need
255+
an explicit override.
256+
257+
For end-to-end metric verification, observe at least one normal 60-second periodic export, then
258+
terminate or replace a worker-pool revision to exercise the forced shutdown flush. Confirm that
259+
both exports reach Managed Service for Prometheus and that the collector logs contain no
260+
`Duplicate TimeSeries` rejection. A short smoke test that exercises only one of these paths is not
261+
sufficient evidence for the cumulative-metric pipeline.
262+
263+
## Shutdown
264+
265+
Cloud Run sends `SIGTERM` and allows 10 seconds before `SIGKILL`. The shutdown hook reserves six
266+
seconds for graceful worker shutdown, one second for forced shutdown if necessary, and two seconds
267+
for the plugin's Temporal-metrics and OpenTelemetry flush before closing the service stubs. The
268+
flush runs after worker termination so it includes telemetry emitted by finishing tasks.
269+
Long-running Activities must still use heartbeats and cancellation handling so they can stop within
270+
the platform shutdown window.

gcp-opentelemetry/build.gradle

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
plugins {
2+
id 'application'
3+
}
4+
5+
def temporalGcpVersion = findProperty('temporalGcpVersion') ?: '1.37.0-SNAPSHOT'
6+
7+
repositories {
8+
if (findProperty('useMavenLocal') == 'true') {
9+
mavenLocal()
10+
}
11+
}
12+
13+
dependencies {
14+
implementation "io.temporal:temporal-sdk:$temporalGcpVersion"
15+
implementation "io.temporal:temporal-envconfig:$temporalGcpVersion"
16+
implementation "io.temporal:temporal-gcp:$temporalGcpVersion"
17+
18+
runtimeOnly 'ch.qos.logback:logback-classic:1.5.6'
19+
20+
testImplementation "io.temporal:temporal-testing:$temporalGcpVersion"
21+
testImplementation 'junit:junit:4.13.2'
22+
testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.10.3'
23+
24+
errorproneJavac 'com.google.errorprone:javac:9+181-r4173-1'
25+
errorprone 'com.google.errorprone:error_prone_core:2.28.0'
26+
}
27+
28+
application {
29+
mainClass = 'io.temporal.samples.gcp.GcpOpenTelemetryWorker'
30+
}

0 commit comments

Comments
 (0)