diff --git a/contrib/temporal-opentelemetry-v2/build.gradle b/contrib/temporal-opentelemetry-v2/build.gradle
new file mode 100644
index 0000000000..3fb51d1651
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/build.gradle
@@ -0,0 +1,28 @@
+description = '''Temporal Java SDK OpenTelemetry v2 Module'''
+
+ext {
+ otelVersion = '1.66.0'
+}
+
+dependencies {
+ api platform("io.opentelemetry:opentelemetry-bom:$otelVersion")
+
+ compileOnly project(':temporal-serviceclient')
+ compileOnly project(':temporal-sdk')
+ compileOnly "javax.annotation:javax.annotation-api:$annotationApiVersion"
+
+ implementation "com.google.guava:guava:$guavaVersion"
+
+ api "io.opentelemetry:opentelemetry-api"
+ api "io.opentelemetry:opentelemetry-sdk-trace"
+ api "io.opentelemetry:opentelemetry-sdk-metrics"
+ api "io.opentelemetry:opentelemetry-sdk-logs"
+
+ testImplementation project(':temporal-sdk')
+ testImplementation project(':temporal-serviceclient')
+ testImplementation project(':temporal-testing')
+ testImplementation "io.opentelemetry:opentelemetry-sdk-testing"
+ testImplementation "junit:junit:${junitVersion}"
+
+ testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}"
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryActivityClientInterceptor.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryActivityClientInterceptor.java
new file mode 100644
index 0000000000..3a06c5bde9
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryActivityClientInterceptor.java
@@ -0,0 +1,20 @@
+package io.temporal.opentelemetry.v2;
+
+import io.temporal.common.interceptors.ActivityClientCallsInterceptor;
+import io.temporal.common.interceptors.ActivityClientInterceptorBase;
+import io.temporal.opentelemetry.v2.internal.InterceptorTracer;
+import io.temporal.opentelemetry.v2.internal.OpenTelemetryActivityClientCallsInterceptor;
+
+public class OpenTelemetryActivityClientInterceptor extends ActivityClientInterceptorBase {
+ private final InterceptorTracer tracer;
+
+ public OpenTelemetryActivityClientInterceptor(InterceptorTracer tracer) {
+ this.tracer = tracer;
+ }
+
+ @Override
+ public ActivityClientCallsInterceptor activityClientCallsInterceptor(
+ ActivityClientCallsInterceptor next) {
+ return new OpenTelemetryActivityClientCallsInterceptor(tracer, next);
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryClientInterceptor.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryClientInterceptor.java
new file mode 100644
index 0000000000..fb7af88eab
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryClientInterceptor.java
@@ -0,0 +1,20 @@
+package io.temporal.opentelemetry.v2;
+
+import io.temporal.common.interceptors.WorkflowClientCallsInterceptor;
+import io.temporal.common.interceptors.WorkflowClientInterceptorBase;
+import io.temporal.opentelemetry.v2.internal.InterceptorTracer;
+import io.temporal.opentelemetry.v2.internal.OpenTelemetryWorkflowClientCallsInterceptor;
+
+public class OpenTelemetryClientInterceptor extends WorkflowClientInterceptorBase {
+ private final InterceptorTracer tracer;
+
+ public OpenTelemetryClientInterceptor(InterceptorTracer tracer) {
+ this.tracer = tracer;
+ }
+
+ @Override
+ public WorkflowClientCallsInterceptor workflowClientCallsInterceptor(
+ WorkflowClientCallsInterceptor next) {
+ return new OpenTelemetryWorkflowClientCallsInterceptor(tracer, next);
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryNexusClientInterceptor.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryNexusClientInterceptor.java
new file mode 100644
index 0000000000..46853d0bc3
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryNexusClientInterceptor.java
@@ -0,0 +1,19 @@
+package io.temporal.opentelemetry.v2;
+
+import io.temporal.common.interceptors.NexusClientCallsInterceptor;
+import io.temporal.common.interceptors.NexusClientInterceptorBase;
+import io.temporal.opentelemetry.v2.internal.InterceptorTracer;
+import io.temporal.opentelemetry.v2.internal.OpenTelemetryNexusClientCallsInterceptor;
+
+public class OpenTelemetryNexusClientInterceptor extends NexusClientInterceptorBase {
+ private final InterceptorTracer tracer;
+
+ public OpenTelemetryNexusClientInterceptor(InterceptorTracer tracer) {
+ this.tracer = tracer;
+ }
+
+ @Override
+ public NexusClientCallsInterceptor nexusClientCallsInterceptor(NexusClientCallsInterceptor next) {
+ return new OpenTelemetryNexusClientCallsInterceptor(tracer, next);
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryPlugin.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryPlugin.java
new file mode 100644
index 0000000000..8bef3a9ae3
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryPlugin.java
@@ -0,0 +1,72 @@
+package io.temporal.opentelemetry.v2;
+
+import io.temporal.common.Experimental;
+import io.temporal.common.SimplePlugin;
+import io.temporal.opentelemetry.v2.internal.InterceptorTracer;
+import io.temporal.opentelemetry.v2.internal.OpenTelemetryContextPropagator;
+
+/**
+ * OpenTelemetry v2 plugin for Temporal clients and workers.
+ *
+ *
Set it on {@code WorkflowServiceStubsOptions}, {@code WorkflowClientOptions}, or {@code
+ * WorkerFactoryOptions}. The SDK propagates plugins down that chain.
+ *
+ *
Register a {@link ReplaySafeOpenTelemetry} with {@code GlobalOpenTelemetry.set} before calling
+ * {@link Builder#build()}.
+ */
+@Experimental
+public final class OpenTelemetryPlugin extends SimplePlugin {
+ public static final String NAME = "io.temporal.opentelemetry.v2";
+
+ private OpenTelemetryPlugin(InterceptorTracer tracer) {
+ super(
+ SimplePlugin.newBuilder(NAME)
+ .addClientInterceptors(new OpenTelemetryClientInterceptor(tracer))
+ .addScheduleClientInterceptors(new OpenTelemetryScheduleClientInterceptor(tracer))
+ .addActivityClientInterceptors(new OpenTelemetryActivityClientInterceptor(tracer))
+ .addNexusClientInterceptors(new OpenTelemetryNexusClientInterceptor(tracer))
+ .addWorkerInterceptors(new OpenTelemetryWorkerInterceptor(tracer))
+ .addContextPropagators(new OpenTelemetryContextPropagator()));
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ /** Every option is optional; an unset one keeps its default. */
+ public static final class Builder {
+ private String headerKey = "_tracer-data";
+ private boolean addTemporalSpans;
+
+ private Builder() {}
+
+ /**
+ * The Temporal header key to serialize the span to. Defaults to {@code _tracer-data}, which
+ * Temporal uses; overriding it breaks trace continuity with workers using the standard key.
+ */
+ public Builder setHeaderKey(String headerKey) {
+ this.headerKey = headerKey;
+ return this;
+ }
+
+ /**
+ * Whether to create spans for Temporal operations such as StartWorkflow, RunWorkflow, and
+ * RunActivity. Defaults to false: trace context still propagates through Temporal headers, so
+ * spans created by application code remain connected.
+ */
+ public Builder setAddTemporalSpans(boolean addTemporalSpans) {
+ this.addTemporalSpans = addTemporalSpans;
+ return this;
+ }
+
+ public OpenTelemetryPlugin build() {
+ if (!ReplaySafeOpenTelemetry.isRegisteredGlobally()) {
+ throw new IllegalStateException(
+ "the global OpenTelemetry must be a ReplaySafeOpenTelemetry; build one with "
+ + "ReplaySafeOpenTelemetry.newBuilder() and register it with "
+ + "GlobalOpenTelemetry.set before building this plugin");
+ }
+ return new OpenTelemetryPlugin(new InterceptorTracer(headerKey, addTemporalSpans));
+ }
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryScheduleClientInterceptor.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryScheduleClientInterceptor.java
new file mode 100644
index 0000000000..01ab09bad2
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryScheduleClientInterceptor.java
@@ -0,0 +1,20 @@
+package io.temporal.opentelemetry.v2;
+
+import io.temporal.common.interceptors.ScheduleClientCallsInterceptor;
+import io.temporal.common.interceptors.ScheduleClientInterceptorBase;
+import io.temporal.opentelemetry.v2.internal.InterceptorTracer;
+import io.temporal.opentelemetry.v2.internal.OpenTelemetryScheduleClientCallsInterceptor;
+
+public class OpenTelemetryScheduleClientInterceptor extends ScheduleClientInterceptorBase {
+ private final InterceptorTracer tracer;
+
+ public OpenTelemetryScheduleClientInterceptor(InterceptorTracer tracer) {
+ this.tracer = tracer;
+ }
+
+ @Override
+ public ScheduleClientCallsInterceptor scheduleClientCallsInterceptor(
+ ScheduleClientCallsInterceptor next) {
+ return new OpenTelemetryScheduleClientCallsInterceptor(tracer, next);
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryWorkerInterceptor.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryWorkerInterceptor.java
new file mode 100644
index 0000000000..cf7d414f4a
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/OpenTelemetryWorkerInterceptor.java
@@ -0,0 +1,35 @@
+package io.temporal.opentelemetry.v2;
+
+import io.nexusrpc.handler.OperationContext;
+import io.temporal.common.interceptors.ActivityInboundCallsInterceptor;
+import io.temporal.common.interceptors.NexusOperationInboundCallsInterceptor;
+import io.temporal.common.interceptors.WorkerInterceptor;
+import io.temporal.common.interceptors.WorkflowInboundCallsInterceptor;
+import io.temporal.opentelemetry.v2.internal.InterceptorTracer;
+import io.temporal.opentelemetry.v2.internal.OpenTelemetryActivityInboundCallsInterceptor;
+import io.temporal.opentelemetry.v2.internal.OpenTelemetryNexusOperationInboundCallsInterceptor;
+import io.temporal.opentelemetry.v2.internal.OpenTelemetryWorkflowInboundCallsInterceptor;
+
+public class OpenTelemetryWorkerInterceptor implements WorkerInterceptor {
+ private final InterceptorTracer tracer;
+
+ public OpenTelemetryWorkerInterceptor(InterceptorTracer tracer) {
+ this.tracer = tracer;
+ }
+
+ @Override
+ public WorkflowInboundCallsInterceptor interceptWorkflow(WorkflowInboundCallsInterceptor next) {
+ return new OpenTelemetryWorkflowInboundCallsInterceptor(tracer, next);
+ }
+
+ @Override
+ public ActivityInboundCallsInterceptor interceptActivity(ActivityInboundCallsInterceptor next) {
+ return new OpenTelemetryActivityInboundCallsInterceptor(tracer, next);
+ }
+
+ @Override
+ public NexusOperationInboundCallsInterceptor interceptNexusOperation(
+ OperationContext context, NexusOperationInboundCallsInterceptor next) {
+ return new OpenTelemetryNexusOperationInboundCallsInterceptor(tracer, next);
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/ReplaySafeOpenTelemetry.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/ReplaySafeOpenTelemetry.java
new file mode 100644
index 0000000000..d12e51c1fd
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/ReplaySafeOpenTelemetry.java
@@ -0,0 +1,184 @@
+package io.temporal.opentelemetry.v2;
+
+import io.opentelemetry.api.GlobalOpenTelemetry;
+import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.api.baggage.propagation.W3CBaggagePropagator;
+import io.opentelemetry.api.logs.LoggerProvider;
+import io.opentelemetry.api.metrics.MeterProvider;
+import io.opentelemetry.api.trace.Tracer;
+import io.opentelemetry.api.trace.TracerBuilder;
+import io.opentelemetry.api.trace.TracerProvider;
+import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator;
+import io.opentelemetry.context.propagation.ContextPropagators;
+import io.opentelemetry.context.propagation.TextMapPropagator;
+import io.opentelemetry.sdk.logs.SdkLoggerProvider;
+import io.opentelemetry.sdk.logs.SdkLoggerProviderBuilder;
+import io.opentelemetry.sdk.metrics.SdkMeterProvider;
+import io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder;
+import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder;
+import io.temporal.common.Experimental;
+import io.temporal.opentelemetry.v2.internal.ReplaySafeIdGenerator;
+import io.temporal.opentelemetry.v2.internal.ReplaySafeTracer;
+import java.io.Closeable;
+import javax.annotation.Nonnull;
+
+/**
+ * The {@link OpenTelemetry} to use for OpenTelemetry integration with Temporal. Register it with
+ * {@code GlobalOpenTelemetry.set}; tracers obtained from it are replay safe inside workflows.
+ */
+@Experimental
+public final class ReplaySafeOpenTelemetry implements OpenTelemetry, Closeable {
+ private final ReplaySafeTracerProvider tracerProvider;
+ private final SdkMeterProvider meterProvider; // TODO - Make the meter provider replay safe
+ // TODO: Make the logger provider replay safe and add logger interceptor methods for Temporal and
+ // OpenTelemetry loggers.
+ private final SdkLoggerProvider loggerProvider;
+ private final ContextPropagators propagators;
+
+ private ReplaySafeOpenTelemetry(Builder builder) {
+ this.tracerProvider =
+ new ReplaySafeTracerProvider(
+ builder.tracerProviderBuilder.setIdGenerator(new ReplaySafeIdGenerator()).build());
+ this.meterProvider = builder.meterProviderBuilder.build();
+ this.loggerProvider = builder.loggerProviderBuilder.build();
+ this.propagators = builder.propagators;
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ @Override
+ public TracerProvider getTracerProvider() {
+ return tracerProvider;
+ }
+
+ @Override
+ public MeterProvider getMeterProvider() {
+ return meterProvider;
+ }
+
+ @Override
+ public LoggerProvider getLogsBridge() {
+ return loggerProvider;
+ }
+
+ @Override
+ public ContextPropagators getPropagators() {
+ return propagators;
+ }
+
+ static boolean isRegisteredGlobally() {
+ return GlobalOpenTelemetry.getTracerProvider() instanceof ReplaySafeTracerProvider;
+ }
+
+ /** Shuts down every provider. Call after the clients and workers using them have stopped. */
+ @Override
+ public void close() {
+ tracerProvider.close();
+ meterProvider.close();
+ loggerProvider.close();
+ }
+
+ /** Every provider is optional; an unset one is built from the SDK's default builder. */
+ public static final class Builder {
+ private SdkTracerProviderBuilder tracerProviderBuilder = SdkTracerProvider.builder();
+ private SdkMeterProviderBuilder meterProviderBuilder = SdkMeterProvider.builder();
+ private SdkLoggerProviderBuilder loggerProviderBuilder = SdkLoggerProvider.builder();
+ private ContextPropagators propagators =
+ ContextPropagators.create(
+ TextMapPropagator.composite(
+ W3CTraceContextPropagator.getInstance(), W3CBaggagePropagator.getInstance()));
+
+ private Builder() {}
+
+ public Builder setTracerProviderBuilder(SdkTracerProviderBuilder tracerProviderBuilder) {
+ this.tracerProviderBuilder = tracerProviderBuilder;
+ return this;
+ }
+
+ public Builder setMeterProviderBuilder(SdkMeterProviderBuilder meterProviderBuilder) {
+ this.meterProviderBuilder = meterProviderBuilder;
+ return this;
+ }
+
+ public Builder setLoggerProviderBuilder(SdkLoggerProviderBuilder loggerProviderBuilder) {
+ this.loggerProviderBuilder = loggerProviderBuilder;
+ return this;
+ }
+
+ /**
+ * The propagators returned by {@link ReplaySafeOpenTelemetry#getPropagators()}. Defaults to W3C
+ * trace context plus baggage, which is what {@link OpenTelemetryPlugin} serializes into
+ * Temporal headers when it is left to resolve its propagator from the global.
+ */
+ public Builder setPropagators(ContextPropagators propagators) {
+ this.propagators = propagators;
+ return this;
+ }
+
+ public ReplaySafeOpenTelemetry build() {
+ return new ReplaySafeOpenTelemetry(this);
+ }
+ }
+
+ private static final class ReplaySafeTracerProvider implements TracerProvider, Closeable {
+ private final SdkTracerProvider delegate;
+
+ private ReplaySafeTracerProvider(SdkTracerProvider delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public Tracer get(@Nonnull String instrumentationScopeName) {
+ return new ReplaySafeTracer(delegate.get(instrumentationScopeName), instrumentationScopeName);
+ }
+
+ @Override
+ public Tracer get(
+ @Nonnull String instrumentationScopeName, @Nonnull String instrumentationScopeVersion) {
+ return new ReplaySafeTracer(
+ delegate.get(instrumentationScopeName, instrumentationScopeVersion),
+ instrumentationScopeName);
+ }
+
+ @Override
+ public TracerBuilder tracerBuilder(@Nonnull String instrumentationScopeName) {
+ return new ReplaySafeTracerBuilder(
+ delegate.tracerBuilder(instrumentationScopeName), instrumentationScopeName);
+ }
+
+ @Override
+ public void close() {
+ delegate.close();
+ }
+ }
+
+ private static final class ReplaySafeTracerBuilder implements TracerBuilder {
+ private final TracerBuilder delegate;
+ private final String instrumentationScopeName;
+
+ private ReplaySafeTracerBuilder(TracerBuilder delegate, String instrumentationScopeName) {
+ this.delegate = delegate;
+ this.instrumentationScopeName = instrumentationScopeName;
+ }
+
+ @Override
+ public TracerBuilder setSchemaUrl(@Nonnull String schemaUrl) {
+ delegate.setSchemaUrl(schemaUrl);
+ return this;
+ }
+
+ @Override
+ public TracerBuilder setInstrumentationVersion(@Nonnull String instrumentationScopeVersion) {
+ delegate.setInstrumentationVersion(instrumentationScopeVersion);
+ return this;
+ }
+
+ @Override
+ public Tracer build() {
+ return new ReplaySafeTracer(delegate.build(), instrumentationScopeName);
+ }
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/InterceptorTracer.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/InterceptorTracer.java
new file mode 100644
index 0000000000..171a912700
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/InterceptorTracer.java
@@ -0,0 +1,191 @@
+package io.temporal.opentelemetry.v2.internal;
+
+import io.opentelemetry.api.GlobalOpenTelemetry;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.SpanKind;
+import io.opentelemetry.api.trace.StatusCode;
+import io.opentelemetry.api.trace.Tracer;
+import io.opentelemetry.context.Context;
+import io.opentelemetry.context.Scope;
+import io.temporal.common.interceptors.Header;
+import io.temporal.failure.ApplicationErrorCategory;
+import io.temporal.failure.ApplicationFailure;
+import io.temporal.internal.sync.DestroyWorkflowThreadError;
+import java.util.List;
+import java.util.Map;
+
+/** Wraps intercepted Temporal calls in a span and propagates it through headers. */
+public final class InterceptorTracer {
+ private static final String INSTRUMENTATION_NAME = "temporal-sdk-java";
+
+ private final Tracer tracer;
+ private final SpanCodec codec;
+ private final boolean addTemporalSpans;
+
+ public InterceptorTracer(String headerKey, boolean addTemporalSpans) {
+ this.tracer = GlobalOpenTelemetry.getTracer(INSTRUMENTATION_NAME);
+ this.codec = new SpanCodec(headerKey);
+ this.addTemporalSpans = addTemporalSpans;
+ }
+
+ /**
+ * An intercepted call. {@code E} is the checked exception it declares, or {@link
+ * RuntimeException} when it declares none.
+ */
+ @FunctionalInterface
+ interface Call {
+ R call() throws E;
+ }
+
+ /** An intercepted call with no result. */
+ @FunctionalInterface
+ interface VoidCall {
+ void call() throws E;
+ }
+
+ R traceInbound(
+ String operation, String name, Attributes attributes, Header header, Call call)
+ throws E {
+ return traceInbound(operation, name, attributes, codec.read(header), call);
+ }
+
+ void traceInbound(
+ String operation, String name, Attributes attributes, Header header, VoidCall call)
+ throws E {
+ traceInbound(operation, name, attributes, codec.read(header), asCall(call));
+ }
+
+ R traceNexusInbound(
+ String operation,
+ String name,
+ Attributes attributes,
+ Map nexusHeaders,
+ Call call)
+ throws E {
+ return traceInbound(operation, name, attributes, codec.read(nexusHeaders), call);
+ }
+
+ private R traceInbound(
+ String operation, String name, Attributes attributes, Context parent, Call call)
+ throws E {
+ try (Scope ignoredParent = parent.makeCurrent()) {
+ if (!addTemporalSpans) {
+ return call.call();
+ }
+
+ Span span = startSpan(operation, name, attributes, SpanKind.SERVER);
+ try (Scope ignored = span.makeCurrent()) {
+ return run(span, call);
+ } finally {
+ span.end();
+ }
+ }
+ }
+
+ R traceOutbound(
+ String operation, String name, Attributes attributes, Header header, Call call)
+ throws E {
+ return traceOutbound(operation, name, attributes, () -> codec.write(header), call);
+ }
+
+ void injectOutboundHeader(Header header) {
+ codec.write(header);
+ }
+
+ void clearOutboundHeader(Header header) {
+ codec.clear(header);
+ }
+
+ R traceOutbound(
+ String operation, String name, Attributes attributes, Call call) throws E {
+ return traceOutbound(operation, name, attributes, () -> {}, call);
+ }
+
+ void traceOutbound(
+ String operation, String name, Attributes attributes, Header header, VoidCall call)
+ throws E {
+ traceOutbound(operation, name, attributes, () -> codec.write(header), asCall(call));
+ }
+
+ R traceOutbound(
+ String operation, String name, Attributes attributes, List headers, Call call)
+ throws E {
+ return traceOutbound(operation, name, attributes, () -> headers.forEach(codec::write), call);
+ }
+
+ R traceNexusOutbound(
+ String operation,
+ String name,
+ Attributes attributes,
+ Map nexusHeaders,
+ Call call)
+ throws E {
+ return traceOutbound(operation, name, attributes, () -> codec.write(nexusHeaders), call);
+ }
+
+ private R traceOutbound(
+ String operation, String name, Attributes attributes, Runnable writeHeader, Call call)
+ throws E {
+ if (!addTemporalSpans) {
+ writeHeader.run();
+ return call.call();
+ }
+
+ Span span = startSpan(operation, name, attributes, SpanKind.CLIENT);
+ try (Scope ignored = span.makeCurrent()) {
+ writeHeader.run();
+ return run(span, call);
+ } finally {
+ span.end();
+ }
+ }
+
+ private static Call asCall(VoidCall call) {
+ return () -> {
+ call.call();
+ return null;
+ };
+ }
+
+ /** Records a failure on {@code span} before letting it propagate. */
+ private static R run(Span span, Call call) throws E {
+ try {
+ return call.call();
+ } catch (DestroyWorkflowThreadError unwind) {
+ throw unwind;
+ } catch (Throwable failure) {
+ span.recordException(failure);
+ if (!isBenign(failure)) {
+ span.setStatus(StatusCode.ERROR, failure.toString());
+ }
+ throw failure;
+ }
+ }
+
+ private Span startSpan(String operation, String name, Attributes attributes, SpanKind kind) {
+ try (Scope ignored =
+ Context.current().with(ReplaySafeIdGenerator.INTERCEPTOR_SPAN, true).makeCurrent()) {
+ return tracer
+ .spanBuilder(spanName(operation, name))
+ .setSpanKind(kind)
+ .setAllAttributes(attributes)
+ .startSpan();
+ }
+ }
+
+ static String spanName(String operation, String name) {
+ if (operation.isEmpty()) {
+ return name;
+ }
+ if (name.isEmpty()) {
+ return operation;
+ }
+ return operation + ":" + name;
+ }
+
+ private static boolean isBenign(Throwable failure) {
+ return failure instanceof ApplicationFailure
+ && ((ApplicationFailure) failure).getCategory() == ApplicationErrorCategory.BENIGN;
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryActivityClientCallsInterceptor.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryActivityClientCallsInterceptor.java
new file mode 100644
index 0000000000..fb901c2f59
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryActivityClientCallsInterceptor.java
@@ -0,0 +1,28 @@
+package io.temporal.opentelemetry.v2.internal;
+
+import static io.temporal.opentelemetry.v2.internal.TagKeys.ACTIVITY_ID;
+
+import io.opentelemetry.api.common.Attributes;
+import io.temporal.common.interceptors.ActivityClientCallsInterceptor;
+import io.temporal.common.interceptors.ActivityClientCallsInterceptorBase;
+
+public class OpenTelemetryActivityClientCallsInterceptor
+ extends ActivityClientCallsInterceptorBase {
+ private final InterceptorTracer tracer;
+
+ public OpenTelemetryActivityClientCallsInterceptor(
+ InterceptorTracer tracer, ActivityClientCallsInterceptor next) {
+ super(next);
+ this.tracer = tracer;
+ }
+
+ @Override
+ public StartActivityOutput startActivity(StartActivityInput input) {
+ return tracer.traceOutbound(
+ "StartActivity",
+ input.getActivityType(),
+ Attributes.of(ACTIVITY_ID, input.getOptions().getId()),
+ input.getHeader(),
+ () -> super.startActivity(input));
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryActivityInboundCallsInterceptor.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryActivityInboundCallsInterceptor.java
new file mode 100644
index 0000000000..6da63b04d6
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryActivityInboundCallsInterceptor.java
@@ -0,0 +1,43 @@
+package io.temporal.opentelemetry.v2.internal;
+
+import static io.temporal.opentelemetry.v2.internal.TagKeys.*;
+
+import io.opentelemetry.api.common.Attributes;
+import io.temporal.activity.ActivityExecutionContext;
+import io.temporal.activity.ActivityInfo;
+import io.temporal.common.interceptors.ActivityInboundCallsInterceptor;
+import io.temporal.common.interceptors.ActivityInboundCallsInterceptorBase;
+
+public class OpenTelemetryActivityInboundCallsInterceptor
+ extends ActivityInboundCallsInterceptorBase {
+ private final InterceptorTracer tracer;
+ // Activity code reaches its context through Activity.getExecutionContext(), but interceptors
+ // only see it in init.
+ private ActivityExecutionContext context;
+
+ public OpenTelemetryActivityInboundCallsInterceptor(
+ InterceptorTracer tracer, ActivityInboundCallsInterceptor next) {
+ super(next);
+ this.tracer = tracer;
+ }
+
+ @Override
+ public void init(ActivityExecutionContext context) {
+ this.context = context;
+ super.init(context);
+ }
+
+ @Override
+ public ActivityOutput execute(ActivityInput input) {
+ ActivityInfo info = context.getInfo();
+ return tracer.traceInbound(
+ "RunActivity",
+ info.getActivityType(),
+ Attributes.of(
+ WORKFLOW_ID, info.getWorkflowId(),
+ RUN_ID, info.getWorkflowRunId(),
+ ACTIVITY_ID, info.getActivityId()),
+ input.getHeader(),
+ () -> super.execute(input));
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryContextPropagator.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryContextPropagator.java
new file mode 100644
index 0000000000..a01b582b54
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryContextPropagator.java
@@ -0,0 +1,35 @@
+package io.temporal.opentelemetry.v2.internal;
+
+import io.opentelemetry.context.Context;
+import io.temporal.api.common.v1.Payload;
+import io.temporal.common.context.ContextPropagator;
+import java.util.Collections;
+import java.util.Map;
+
+/** Copies the current OpenTelemetry context between Temporal workflow threads. */
+public final class OpenTelemetryContextPropagator implements ContextPropagator {
+ @Override
+ public String getName() {
+ return "io.temporal.opentelemetry.v2.workflow-context";
+ }
+
+ @Override
+ public Map serializeContext(Object context) {
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public Object deserializeContext(Map header) {
+ return Context.root();
+ }
+
+ @Override
+ public Object getCurrentContext() {
+ return Context.current();
+ }
+
+ @Override
+ public void setCurrentContext(Object context) {
+ TemporalContextStorage.setWorkflowContext((Context) context);
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryNexusClientCallsInterceptor.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryNexusClientCallsInterceptor.java
new file mode 100644
index 0000000000..5cd729a725
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryNexusClientCallsInterceptor.java
@@ -0,0 +1,31 @@
+package io.temporal.opentelemetry.v2.internal;
+
+import static io.temporal.opentelemetry.v2.internal.TagKeys.*;
+
+import io.opentelemetry.api.common.Attributes;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor;
+import io.temporal.common.interceptors.NexusClientCallsInterceptorBase;
+
+public class OpenTelemetryNexusClientCallsInterceptor extends NexusClientCallsInterceptorBase {
+ private final InterceptorTracer tracer;
+
+ public OpenTelemetryNexusClientCallsInterceptor(
+ InterceptorTracer tracer, NexusClientCallsInterceptor next) {
+ super(next);
+ this.tracer = tracer;
+ }
+
+ @Override
+ public StartNexusOperationExecutionOutput startNexusOperationExecution(
+ StartNexusOperationExecutionInput input) {
+ return tracer.traceNexusOutbound(
+ "StartNexusOperation",
+ input.getService() + "/" + input.getOperation(),
+ Attributes.of(
+ NEXUS_ENDPOINT, input.getEndpoint(),
+ NEXUS_SERVICE, input.getService(),
+ NEXUS_OPERATION, input.getOperation()),
+ input.getHeaders(),
+ () -> super.startNexusOperationExecution(input));
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryNexusOperationInboundCallsInterceptor.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryNexusOperationInboundCallsInterceptor.java
new file mode 100644
index 0000000000..c06203c3cc
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryNexusOperationInboundCallsInterceptor.java
@@ -0,0 +1,58 @@
+package io.temporal.opentelemetry.v2.internal;
+
+import static io.temporal.opentelemetry.v2.internal.TagKeys.*;
+
+import io.nexusrpc.OperationException;
+import io.nexusrpc.handler.OperationContext;
+import io.opentelemetry.api.common.Attributes;
+import io.temporal.common.interceptors.NexusOperationInboundCallsInterceptor;
+import io.temporal.common.interceptors.NexusOperationInboundCallsInterceptorBase;
+import io.temporal.common.interceptors.NexusOperationOutboundCallsInterceptor;
+
+public class OpenTelemetryNexusOperationInboundCallsInterceptor
+ extends NexusOperationInboundCallsInterceptorBase {
+
+ private final InterceptorTracer tracer;
+
+ public OpenTelemetryNexusOperationInboundCallsInterceptor(
+ InterceptorTracer tracer, NexusOperationInboundCallsInterceptor next) {
+ super(next);
+ this.tracer = tracer;
+ }
+
+ @Override
+ public void init(NexusOperationOutboundCallsInterceptor outboundCalls) {
+ super.init(new OpenTelemetryNexusOperationOutboundCallsInterceptor(outboundCalls));
+ }
+
+ @Override
+ public StartOperationOutput startOperation(StartOperationInput input) throws OperationException {
+ OperationContext context = input.getOperationContext();
+ return tracer.traceNexusInbound(
+ "RunStartNexusOperationHandler",
+ spanName(context),
+ nexusTags(context),
+ context.getHeaders(),
+ () -> super.startOperation(input));
+ }
+
+ @Override
+ public CancelOperationOutput cancelOperation(CancelOperationInput input) {
+ OperationContext context = input.getOperationContext();
+ return tracer.traceNexusInbound(
+ "RunCancelNexusOperationHandler",
+ spanName(context),
+ nexusTags(context),
+ context.getHeaders(),
+ () -> super.cancelOperation(input));
+ }
+
+ private static String spanName(OperationContext context) {
+ return context.getService() + "/" + context.getOperation();
+ }
+
+ private static Attributes nexusTags(OperationContext context) {
+ return Attributes.of(
+ NEXUS_SERVICE, context.getService(), NEXUS_OPERATION, context.getOperation());
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryNexusOperationOutboundCallsInterceptor.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryNexusOperationOutboundCallsInterceptor.java
new file mode 100644
index 0000000000..c8f1a3e5c8
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryNexusOperationOutboundCallsInterceptor.java
@@ -0,0 +1,12 @@
+package io.temporal.opentelemetry.v2.internal;
+
+import io.temporal.common.interceptors.NexusOperationOutboundCallsInterceptor;
+import io.temporal.common.interceptors.NexusOperationOutboundCallsInterceptorBase;
+
+public class OpenTelemetryNexusOperationOutboundCallsInterceptor
+ extends NexusOperationOutboundCallsInterceptorBase {
+ public OpenTelemetryNexusOperationOutboundCallsInterceptor(
+ NexusOperationOutboundCallsInterceptor next) {
+ super(next);
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryScheduleClientCallsInterceptor.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryScheduleClientCallsInterceptor.java
new file mode 100644
index 0000000000..7cc0d9e98e
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryScheduleClientCallsInterceptor.java
@@ -0,0 +1,61 @@
+package io.temporal.opentelemetry.v2.internal;
+
+import io.opentelemetry.api.common.Attributes;
+import io.temporal.client.schedules.ScheduleActionStartWorkflow;
+import io.temporal.client.schedules.ScheduleUpdate;
+import io.temporal.client.schedules.ScheduleUpdateInput;
+import io.temporal.common.interceptors.Header;
+import io.temporal.common.interceptors.ScheduleClientCallsInterceptor;
+import io.temporal.common.interceptors.ScheduleClientCallsInterceptorBase;
+
+public class OpenTelemetryScheduleClientCallsInterceptor
+ extends ScheduleClientCallsInterceptorBase {
+ private final InterceptorTracer tracer;
+
+ public OpenTelemetryScheduleClientCallsInterceptor(
+ InterceptorTracer tracer, ScheduleClientCallsInterceptor next) {
+ super(next);
+ this.tracer = tracer;
+ }
+
+ @Override
+ public void createSchedule(CreateScheduleInput input) {
+ if (!(input.getSchedule().getAction() instanceof ScheduleActionStartWorkflow)) {
+ super.createSchedule(input);
+ return;
+ }
+
+ Header header = ((ScheduleActionStartWorkflow) input.getSchedule().getAction()).getHeader();
+ tracer.clearOutboundHeader(header);
+ tracer.traceOutbound(
+ "CreateSchedule",
+ input.getId(),
+ Attributes.empty(),
+ header,
+ () -> super.createSchedule(input));
+ }
+
+ @Override
+ public void updateSchedule(UpdateScheduleInput input) {
+ tracer.traceOutbound(
+ "UpdateSchedule",
+ input.getDescription().getId(),
+ Attributes.empty(),
+ () -> {
+ super.updateSchedule(
+ new UpdateScheduleInput(
+ input.getDescription(), updateInput -> applyUpdate(input, updateInput)));
+ return null;
+ });
+ }
+
+ private ScheduleUpdate applyUpdate(UpdateScheduleInput input, ScheduleUpdateInput updateInput) {
+ ScheduleUpdate update = input.getUpdater().apply(updateInput);
+ if (update != null && update.getSchedule().getAction() instanceof ScheduleActionStartWorkflow) {
+ Header header = ((ScheduleActionStartWorkflow) update.getSchedule().getAction()).getHeader();
+ tracer.clearOutboundHeader(header);
+ tracer.injectOutboundHeader(header);
+ }
+ return update;
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryWorkflowClientCallsInterceptor.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryWorkflowClientCallsInterceptor.java
new file mode 100644
index 0000000000..d76609a452
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryWorkflowClientCallsInterceptor.java
@@ -0,0 +1,121 @@
+package io.temporal.opentelemetry.v2.internal;
+
+import static io.temporal.opentelemetry.v2.internal.TagKeys.*;
+
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.common.AttributesBuilder;
+import io.temporal.client.WorkflowUpdateHandle;
+import io.temporal.common.interceptors.WorkflowClientCallsInterceptor;
+import io.temporal.common.interceptors.WorkflowClientCallsInterceptorBase;
+import java.util.Arrays;
+
+public class OpenTelemetryWorkflowClientCallsInterceptor
+ extends WorkflowClientCallsInterceptorBase {
+ private final InterceptorTracer tracer;
+
+ public OpenTelemetryWorkflowClientCallsInterceptor(
+ InterceptorTracer tracer, WorkflowClientCallsInterceptor next) {
+ super(next);
+ this.tracer = tracer;
+ }
+
+ @Override
+ public WorkflowStartOutput start(WorkflowStartInput input) {
+ return tracer.traceOutbound(
+ "StartWorkflow",
+ input.getWorkflowType(),
+ Attributes.of(WORKFLOW_ID, input.getWorkflowId()),
+ input.getHeader(),
+ () -> super.start(input));
+ }
+
+ @Override
+ public WorkflowSignalOutput signal(WorkflowSignalInput input) {
+ return tracer.traceOutbound(
+ "SignalWorkflow",
+ input.getSignalName(),
+ workflowExecutionTags(input.getWorkflowExecution()),
+ input.getHeader(),
+ () -> super.signal(input));
+ }
+
+ @Override
+ public WorkflowSignalWithStartOutput signalWithStart(WorkflowSignalWithStartInput input) {
+ WorkflowStartInput start = input.getWorkflowStartInput();
+ return tracer.traceOutbound(
+ "SignalWithStartWorkflow",
+ start.getWorkflowType(),
+ Attributes.of(WORKFLOW_ID, start.getWorkflowId()),
+ start.getHeader(),
+ () -> super.signalWithStart(input));
+ }
+
+ @Override
+ public QueryOutput query(QueryInput input) {
+ return tracer.traceOutbound(
+ "QueryWorkflow",
+ input.getQueryType(),
+ workflowExecutionTags(input.getWorkflowExecution()),
+ input.getHeader(),
+ () -> super.query(input));
+ }
+
+ @Override
+ public WorkflowUpdateHandle startUpdate(StartUpdateInput input) {
+ AttributesBuilder attributes = workflowExecutionTags(input.getWorkflowExecution()).toBuilder();
+ attributes.put(UPDATE_ID, input.getUpdateId());
+ return tracer.traceOutbound(
+ "StartWorkflowUpdate",
+ input.getUpdateName(),
+ attributes.build(),
+ input.getHeader(),
+ () -> super.startUpdate(input));
+ }
+
+ @Override
+ public CancelOutput cancel(CancelInput input) {
+ return tracer.traceOutbound(
+ "CancelWorkflow",
+ "",
+ workflowExecutionTags(input.getWorkflowExecution()),
+ () -> super.cancel(input));
+ }
+
+ @Override
+ public TerminateOutput terminate(TerminateInput input) {
+ AttributesBuilder attributes = workflowExecutionTags(input.getWorkflowExecution()).toBuilder();
+ if (input.getReason() != null) {
+ attributes.put(TERMINATE_REASON, input.getReason());
+ }
+ return tracer.traceOutbound(
+ "TerminateWorkflow", "", attributes.build(), () -> super.terminate(input));
+ }
+
+ @Override
+ public DescribeWorkflowOutput describe(DescribeWorkflowInput input) {
+ return tracer.traceOutbound(
+ "DescribeWorkflow",
+ "",
+ workflowExecutionTags(input.getWorkflowExecution()),
+ () -> super.describe(input));
+ }
+
+ @Override
+ public WorkflowUpdateWithStartOutput updateWithStart(
+ WorkflowUpdateWithStartInput input) {
+ WorkflowStartInput start = input.getWorkflowStartInput();
+ StartUpdateInput update = input.getStartUpdateInput();
+ // The start header reaches the workflow and the update header reaches the update handler.
+ return tracer.traceOutbound(
+ "UpdateWithStartWorkflow",
+ update.getUpdateName(),
+ Attributes.of(WORKFLOW_ID, start.getWorkflowId(), UPDATE_ID, update.getUpdateId()),
+ Arrays.asList(start.getHeader(), update.getHeader()),
+ () -> super.updateWithStart(input));
+ }
+
+ private static Attributes workflowExecutionTags(
+ io.temporal.api.common.v1.WorkflowExecution execution) {
+ return Attributes.of(WORKFLOW_ID, execution.getWorkflowId(), RUN_ID, execution.getRunId());
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryWorkflowInboundCallsInterceptor.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryWorkflowInboundCallsInterceptor.java
new file mode 100644
index 0000000000..c0d033bde7
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryWorkflowInboundCallsInterceptor.java
@@ -0,0 +1,89 @@
+package io.temporal.opentelemetry.v2.internal;
+
+import static io.temporal.opentelemetry.v2.internal.TagKeys.*;
+
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.common.AttributesBuilder;
+import io.temporal.common.interceptors.WorkflowInboundCallsInterceptor;
+import io.temporal.common.interceptors.WorkflowInboundCallsInterceptorBase;
+import io.temporal.common.interceptors.WorkflowOutboundCallsInterceptor;
+import io.temporal.workflow.Workflow;
+import io.temporal.workflow.WorkflowInfo;
+
+public class OpenTelemetryWorkflowInboundCallsInterceptor
+ extends WorkflowInboundCallsInterceptorBase {
+
+ private final InterceptorTracer tracer;
+
+ public OpenTelemetryWorkflowInboundCallsInterceptor(
+ InterceptorTracer tracer, WorkflowInboundCallsInterceptor next) {
+ super(next);
+ this.tracer = tracer;
+ }
+
+ @Override
+ public void init(WorkflowOutboundCallsInterceptor outboundCalls) {
+ super.init(new OpenTelemetryWorkflowOutboundCallsInterceptor(tracer, outboundCalls));
+ }
+
+ @Override
+ public WorkflowOutput execute(WorkflowInput input) {
+ return tracer.traceInbound(
+ "RunWorkflow",
+ Workflow.getInfo().getWorkflowType(),
+ workflowTags(),
+ input.getHeader(),
+ () -> super.execute(input));
+ }
+
+ @Override
+ public void handleSignal(SignalInput input) {
+ tracer.traceInbound(
+ "HandleSignal",
+ input.getSignalName(),
+ workflowTags(),
+ input.getHeader(),
+ () -> super.handleSignal(input));
+ }
+
+ @Override
+ public QueryOutput handleQuery(QueryInput input) {
+ return tracer.traceInbound(
+ "HandleQuery",
+ input.getQueryName(),
+ workflowTags(),
+ input.getHeader(),
+ () -> super.handleQuery(input));
+ }
+
+ @Override
+ public void validateUpdate(UpdateInput input) {
+ tracer.traceInbound(
+ "ValidateUpdate",
+ input.getUpdateName(),
+ updateTags(),
+ input.getHeader(),
+ () -> super.validateUpdate(input));
+ }
+
+ @Override
+ public UpdateOutput executeUpdate(UpdateInput input) {
+ return tracer.traceInbound(
+ "HandleUpdate",
+ input.getUpdateName(),
+ updateTags(),
+ input.getHeader(),
+ () -> super.executeUpdate(input));
+ }
+
+ private static Attributes workflowTags() {
+ WorkflowInfo info = Workflow.getInfo();
+ return Attributes.of(WORKFLOW_ID, info.getWorkflowId(), RUN_ID, info.getRunId());
+ }
+
+ private static Attributes updateTags() {
+ AttributesBuilder tags = workflowTags().toBuilder();
+ Workflow.getCurrentUpdateInfo().ifPresent(update -> tags.put(UPDATE_ID, update.getUpdateId()));
+ return tags.build();
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryWorkflowOutboundCallsInterceptor.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryWorkflowOutboundCallsInterceptor.java
new file mode 100644
index 0000000000..e6b852670e
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/OpenTelemetryWorkflowOutboundCallsInterceptor.java
@@ -0,0 +1,120 @@
+package io.temporal.opentelemetry.v2.internal;
+
+import static io.temporal.opentelemetry.v2.internal.TagKeys.*;
+
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.common.AttributesBuilder;
+import io.temporal.api.common.v1.WorkflowExecution;
+import io.temporal.common.interceptors.WorkflowOutboundCallsInterceptor;
+import io.temporal.common.interceptors.WorkflowOutboundCallsInterceptorBase;
+import io.temporal.workflow.Workflow;
+import io.temporal.workflow.WorkflowInfo;
+
+public class OpenTelemetryWorkflowOutboundCallsInterceptor
+ extends WorkflowOutboundCallsInterceptorBase {
+
+ private final InterceptorTracer tracer;
+
+ public OpenTelemetryWorkflowOutboundCallsInterceptor(
+ InterceptorTracer tracer, WorkflowOutboundCallsInterceptor next) {
+ super(next);
+ this.tracer = tracer;
+ }
+
+ @Override
+ public ActivityOutput executeActivity(ActivityInput input) {
+ return tracer.traceOutbound(
+ "StartActivity",
+ input.getActivityName(),
+ workflowTags(),
+ input.getHeader(),
+ () -> super.executeActivity(input));
+ }
+
+ @Override
+ public LocalActivityOutput executeLocalActivity(LocalActivityInput input) {
+ return tracer.traceOutbound(
+ "StartActivity",
+ input.getActivityName(),
+ workflowTags(),
+ input.getHeader(),
+ () -> super.executeLocalActivity(input));
+ }
+
+ @Override
+ public ChildWorkflowOutput executeChildWorkflow(ChildWorkflowInput input) {
+ return tracer.traceOutbound(
+ "StartChildWorkflow",
+ input.getWorkflowType(),
+ childWorkflowTags(input),
+ input.getHeader(),
+ () -> super.executeChildWorkflow(input));
+ }
+
+ @Override
+ public ExecuteNexusOperationOutput executeNexusOperation(
+ ExecuteNexusOperationInput input) {
+ return tracer.traceNexusOutbound(
+ "StartNexusOperation",
+ input.getService() + "/" + input.getOperation(),
+ nexusTags(input),
+ input.getHeaders(),
+ () -> super.executeNexusOperation(input));
+ }
+
+ @Override
+ public SignalExternalOutput signalExternalWorkflow(SignalExternalInput input) {
+ return tracer.traceOutbound(
+ "SignalExternalWorkflow",
+ input.getSignalName(),
+ workflowExecutionTags(input.getExecution()),
+ input.getHeader(),
+ () -> super.signalExternalWorkflow(input));
+ }
+
+ @Override
+ public CancelWorkflowOutput cancelWorkflow(CancelWorkflowInput input) {
+ return tracer.traceOutbound(
+ "CancelWorkflow",
+ "",
+ workflowExecutionTags(input.getExecution()),
+ () -> super.cancelWorkflow(input));
+ }
+
+ @Override
+ public void continueAsNew(ContinueAsNewInput input) {
+ String workflowType = input.getWorkflowType();
+ if (workflowType == null) {
+ workflowType = Workflow.getInfo().getWorkflowType();
+ }
+ tracer.traceOutbound(
+ "ContinueAsNew",
+ workflowType,
+ workflowTags(),
+ input.getHeader(),
+ () -> super.continueAsNew(input));
+ }
+
+ private static Attributes workflowTags() {
+ WorkflowInfo info = Workflow.getInfo();
+ return Attributes.of(WORKFLOW_ID, info.getWorkflowId(), RUN_ID, info.getRunId());
+ }
+
+ private static Attributes workflowExecutionTags(WorkflowExecution execution) {
+ return Attributes.of(WORKFLOW_ID, execution.getWorkflowId(), RUN_ID, execution.getRunId());
+ }
+
+ private static Attributes childWorkflowTags(ChildWorkflowInput> input) {
+ return Attributes.of(WORKFLOW_ID, input.getWorkflowId());
+ }
+
+ private static Attributes nexusTags(ExecuteNexusOperationInput> input) {
+ AttributesBuilder tags =
+ workflowTags().toBuilder()
+ .put(NEXUS_SERVICE, input.getService())
+ .put(NEXUS_OPERATION, input.getOperation())
+ .put(NEXUS_ENDPOINT, input.getEndpoint());
+
+ return tags.build();
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/ReplaySafeIdGenerator.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/ReplaySafeIdGenerator.java
new file mode 100644
index 0000000000..803947f0f5
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/ReplaySafeIdGenerator.java
@@ -0,0 +1,84 @@
+package io.temporal.opentelemetry.v2.internal;
+
+import io.opentelemetry.api.trace.SpanId;
+import io.opentelemetry.api.trace.TraceId;
+import io.opentelemetry.context.Context;
+import io.opentelemetry.context.ContextKey;
+import io.opentelemetry.sdk.trace.IdGenerator;
+import io.temporal.workflow.Workflow;
+import io.temporal.workflow.unsafe.WorkflowUnsafe;
+import java.util.Random;
+import javax.annotation.Nullable;
+
+/**
+ * Generates span and trace IDs that are replay safe.
+ *
+ * Mirrors OpenTelemetry's RandomIdGenerator,
+ * replacing its platform random source with a workflow random stream.
+ *
+ *
Note: {@link Random} has 48 bits of state, so IDs can collide across workflows once an
+ * installation has generated on the order of 2^24 trace IDs. The JDK has no deterministic random
+ * source with a wider seed that can also be reseeded. A random source with more state may be
+ * considered in the future.
+ */
+public final class ReplaySafeIdGenerator implements IdGenerator {
+ static final ContextKey INTERCEPTOR_SPAN = ContextKey.named("temporal-interceptor-span");
+ private static final String INTERCEPTOR_STREAM = "io.temporal.opentelemetry.v2/interceptor";
+ private static final String APPLICATION_STREAM = "io.temporal.opentelemetry.v2/application";
+
+ private static final long INVALID_ID = 0;
+
+ @Override
+ public String generateSpanId() {
+ Random stream = getStream();
+ if (stream == null) {
+ return IdGenerator.random().generateSpanId();
+ }
+
+ long id;
+ do {
+ id = stream.nextLong();
+ } while (id == INVALID_ID);
+ return SpanId.fromLong(id);
+ }
+
+ @Override
+ public String generateTraceId() {
+ Random stream = getStream();
+ if (stream == null) {
+ return IdGenerator.random().generateTraceId();
+ }
+
+ long idHi = stream.nextLong();
+ long idLo;
+ do {
+ idLo = stream.nextLong();
+ } while (idLo == INVALID_ID);
+ return TraceId.fromLongs(idHi, idLo);
+ }
+
+ /**
+ * Interceptor spans and application spans draw from separate streams so their IDs never collide.
+ * Null means the regular PRNG can be used.
+ */
+ @Nullable
+ private static Random getStream() {
+ if (!WorkflowUnsafe.isWorkflowThread() || !WorkflowUnsafe.isSubjectToReplay()) {
+ return null;
+ }
+
+ Context context = Context.current();
+ if (context.get(INTERCEPTOR_SPAN) != null) {
+ return Workflow.getRandomStream(INTERCEPTOR_STREAM);
+ }
+
+ String tracerName = context.get(ReplaySafeTracer.TRACER_NAME);
+ if (tracerName == null) {
+ throw new IllegalStateException(
+ "Workflow span started without a replay safe tracer. Ensure tracers used in workflows "
+ + "are obtained from ReplaySafeOpenTelemetry");
+ }
+ return Workflow.getRandomStream(APPLICATION_STREAM + "/" + tracerName);
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/ReplaySafeSpan.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/ReplaySafeSpan.java
new file mode 100644
index 0000000000..08b41b5642
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/ReplaySafeSpan.java
@@ -0,0 +1,84 @@
+package io.temporal.opentelemetry.v2.internal;
+
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.SpanContext;
+import io.opentelemetry.api.trace.StatusCode;
+import io.temporal.workflow.unsafe.WorkflowUnsafe;
+import java.util.concurrent.TimeUnit;
+
+/** Wraps a span so that replayed code does not end it, which would export a duplicate. */
+public final class ReplaySafeSpan implements Span {
+ private final Span delegate;
+
+ public ReplaySafeSpan(Span delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public void end() {
+ if (WorkflowUnsafe.isWorkflowThread()
+ && WorkflowUnsafe.isSubjectToReplay()
+ && WorkflowUnsafe.isReplaying()) {
+ return;
+ }
+ delegate.end();
+ }
+
+ @Override
+ public void end(long timestamp, TimeUnit unit) {
+ if (WorkflowUnsafe.isWorkflowThread()
+ && WorkflowUnsafe.isSubjectToReplay()
+ && WorkflowUnsafe.isReplaying()) {
+ return;
+ }
+ delegate.end(timestamp, unit);
+ }
+
+ @Override
+ public Span setAttribute(AttributeKey key, T value) {
+ delegate.setAttribute(key, value);
+ return this;
+ }
+
+ @Override
+ public Span addEvent(String name, Attributes attributes) {
+ delegate.addEvent(name, attributes);
+ return this;
+ }
+
+ @Override
+ public Span addEvent(String name, Attributes attributes, long timestamp, TimeUnit unit) {
+ delegate.addEvent(name, attributes, timestamp, unit);
+ return this;
+ }
+
+ @Override
+ public Span setStatus(StatusCode statusCode, String description) {
+ delegate.setStatus(statusCode, description);
+ return this;
+ }
+
+ @Override
+ public Span recordException(Throwable exception, Attributes additionalAttributes) {
+ delegate.recordException(exception, additionalAttributes);
+ return this;
+ }
+
+ @Override
+ public Span updateName(String name) {
+ delegate.updateName(name);
+ return this;
+ }
+
+ @Override
+ public SpanContext getSpanContext() {
+ return delegate.getSpanContext();
+ }
+
+ @Override
+ public boolean isRecording() {
+ return delegate.isRecording();
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/ReplaySafeTracer.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/ReplaySafeTracer.java
new file mode 100644
index 0000000000..daa68b8319
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/ReplaySafeTracer.java
@@ -0,0 +1,130 @@
+package io.temporal.opentelemetry.v2.internal;
+
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.SpanBuilder;
+import io.opentelemetry.api.trace.SpanContext;
+import io.opentelemetry.api.trace.SpanKind;
+import io.opentelemetry.api.trace.Tracer;
+import io.opentelemetry.context.Context;
+import io.opentelemetry.context.ContextKey;
+import io.opentelemetry.context.Scope;
+import io.temporal.workflow.Workflow;
+import io.temporal.workflow.unsafe.WorkflowUnsafe;
+import java.util.concurrent.TimeUnit;
+import javax.annotation.Nonnull;
+
+/**
+ * Wraps a tracer so the spans it starts inside a workflow are replay safe. The tracer name is
+ * published on the current {@link Context} while the span starts, which is where {@link
+ * ReplaySafeIdGenerator} reads it.
+ */
+public final class ReplaySafeTracer implements Tracer {
+ /** The instrumentation name of the tracer starting the current span. */
+ @Nonnull static final ContextKey TRACER_NAME = ContextKey.named("temporal-tracer-name");
+
+ private final Tracer delegate;
+ private final String name;
+
+ public ReplaySafeTracer(Tracer delegate, String name) {
+ this.delegate = delegate;
+ this.name = name;
+ }
+
+ @Override
+ public SpanBuilder spanBuilder(String spanName) {
+ return new NamedStreamSpanBuilder(delegate.spanBuilder(spanName), name);
+ }
+
+ private static final class NamedStreamSpanBuilder implements SpanBuilder {
+ private final SpanBuilder delegate;
+ private final String tracerName;
+ private boolean startTimestampSet;
+
+ NamedStreamSpanBuilder(SpanBuilder delegate, String tracerName) {
+ this.delegate = delegate;
+ this.tracerName = tracerName;
+ }
+
+ @Override
+ public Span startSpan() {
+ if (WorkflowUnsafe.isWorkflowThread()
+ && WorkflowUnsafe.isSubjectToReplay()
+ && WorkflowUnsafe.isReplaying()
+ && !startTimestampSet) {
+ delegate.setStartTimestamp(Workflow.currentTimeMillis(), TimeUnit.MILLISECONDS);
+ }
+ try (Scope ignored = Context.current().with(TRACER_NAME, tracerName).makeCurrent()) {
+ return new ReplaySafeSpan(delegate.startSpan());
+ }
+ }
+
+ @Override
+ public SpanBuilder setParent(Context context) {
+ delegate.setParent(context);
+ return this;
+ }
+
+ @Override
+ public SpanBuilder setNoParent() {
+ delegate.setNoParent();
+ return this;
+ }
+
+ @Override
+ public SpanBuilder addLink(SpanContext spanContext) {
+ delegate.addLink(spanContext);
+ return this;
+ }
+
+ @Override
+ public SpanBuilder addLink(SpanContext spanContext, Attributes attributes) {
+ delegate.addLink(spanContext, attributes);
+ return this;
+ }
+
+ @Override
+ public SpanBuilder setAttribute(String key, String value) {
+ delegate.setAttribute(key, value);
+ return this;
+ }
+
+ @Override
+ public SpanBuilder setAttribute(String key, long value) {
+ delegate.setAttribute(key, value);
+ return this;
+ }
+
+ @Override
+ public SpanBuilder setAttribute(String key, double value) {
+ delegate.setAttribute(key, value);
+ return this;
+ }
+
+ @Override
+ public SpanBuilder setAttribute(String key, boolean value) {
+ delegate.setAttribute(key, value);
+ return this;
+ }
+
+ @Override
+ public SpanBuilder setAttribute(AttributeKey key, T value) {
+ delegate.setAttribute(key, value);
+ return this;
+ }
+
+ @Override
+ public SpanBuilder setSpanKind(SpanKind spanKind) {
+ delegate.setSpanKind(spanKind);
+ return this;
+ }
+
+ @Override
+ public SpanBuilder setStartTimestamp(long startTimestamp, TimeUnit unit) {
+ startTimestampSet = true;
+ delegate.setStartTimestamp(startTimestamp, unit);
+ return this;
+ }
+ }
+}
diff --git a/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/SpanCodec.java b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/SpanCodec.java
new file mode 100644
index 0000000000..a7c38fe927
--- /dev/null
+++ b/contrib/temporal-opentelemetry-v2/src/main/java/io/temporal/opentelemetry/v2/internal/SpanCodec.java
@@ -0,0 +1,121 @@
+package io.temporal.opentelemetry.v2.internal;
+
+import com.google.common.reflect.TypeToken;
+import io.opentelemetry.api.GlobalOpenTelemetry;
+import io.opentelemetry.context.Context;
+import io.opentelemetry.context.propagation.TextMapGetter;
+import io.opentelemetry.context.propagation.TextMapPropagator;
+import io.opentelemetry.context.propagation.TextMapSetter;
+import io.temporal.api.common.v1.Payload;
+import io.temporal.common.converter.DefaultDataConverter;
+import io.temporal.common.converter.StdConverterBackwardsCompatAdapter;
+import io.temporal.common.interceptors.Header;
+import java.lang.reflect.Type;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+import java.util.TreeMap;
+import javax.annotation.Nullable;
+
+/**
+ * Serializes the current span and baggage into Temporal and Nexus headers and reads them back.
+ *
+ * Temporal headers carry one {@link Properties} payload under the configured key. Nexus headers
+ * are flat and use HTTP header semantics, so they are read case-insensitively.
+ */
+final class SpanCodec {
+ private static final TextMapSetter