Skip to content

Commit 183cb07

Browse files
torosentCopilot
andcommitted
Add paired Client+Server spans matching .NET SDK trace structure
- Added createClientSpan() to TracingHelper for orchestrator scheduling spans - TaskOrchestrationExecutor creates Client-kind spans when scheduling activities and sub-orchestrations (only during non-replay to avoid duplicates) - DurableTaskGrpcWorker creates orchestration Server span only for first execution - Trace now shows 14 spans with Depth 3, matching .NET SDK exactly: create_orchestration (root) → orchestration (server) → activity (client) → activity (server) for each task - Updated screenshots showing paired Client+Server span hierarchy - Added createClientSpan test coverage (2 new tests, 21 total passing) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9691eb1 commit 183cb07

File tree

9 files changed

+188
-25
lines changed

9 files changed

+188
-25
lines changed

client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorker.java

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -185,20 +185,29 @@ public void startAndBlock() {
185185
.findFirst()
186186
.orElse(null);
187187

188+
// Only create orchestration span for the first execution (not replays).
189+
// First execution has EXECUTIONSTARTED in newEvents; replays have it in pastEvents.
190+
boolean isFirstExecution = orchestratorRequest.getNewEventsList().stream()
191+
.anyMatch(event -> event.getEventTypeCase() == HistoryEvent.EventTypeCase.EXECUTIONSTARTED);
192+
188193
TraceContext orchTraceCtx = (startedEvent != null && startedEvent.hasParentTraceContext())
189194
? startedEvent.getParentTraceContext() : null;
190195
String orchName = startedEvent != null ? startedEvent.getName() : "";
191196

192-
Map<String, String> orchSpanAttrs = new HashMap<>();
193-
orchSpanAttrs.put(TracingHelper.ATTR_TYPE, TracingHelper.TYPE_ORCHESTRATION);
194-
orchSpanAttrs.put(TracingHelper.ATTR_TASK_NAME, orchName);
195-
orchSpanAttrs.put(TracingHelper.ATTR_INSTANCE_ID, orchestratorRequest.getInstanceId());
196-
Span orchestrationSpan = TracingHelper.startSpan(
197-
TracingHelper.TYPE_ORCHESTRATION + ":" + orchName,
198-
orchTraceCtx,
199-
SpanKind.SERVER,
200-
orchSpanAttrs);
201-
Scope orchestrationScope = orchestrationSpan.makeCurrent();
197+
Span orchestrationSpan = null;
198+
Scope orchestrationScope = null;
199+
if (isFirstExecution) {
200+
Map<String, String> orchSpanAttrs = new HashMap<>();
201+
orchSpanAttrs.put(TracingHelper.ATTR_TYPE, TracingHelper.TYPE_ORCHESTRATION);
202+
orchSpanAttrs.put(TracingHelper.ATTR_TASK_NAME, orchName);
203+
orchSpanAttrs.put(TracingHelper.ATTR_INSTANCE_ID, orchestratorRequest.getInstanceId());
204+
orchestrationSpan = TracingHelper.startSpan(
205+
TracingHelper.TYPE_ORCHESTRATION + ":" + orchName,
206+
orchTraceCtx,
207+
SpanKind.SERVER,
208+
orchSpanAttrs);
209+
orchestrationScope = orchestrationSpan.makeCurrent();
210+
}
202211

203212
TaskOrchestratorResult taskOrchestratorResult;
204213
try {
@@ -207,13 +216,13 @@ public void startAndBlock() {
207216
orchestratorRequest.getNewEventsList());
208217
} catch (Throwable e) {
209218
TracingHelper.endSpan(orchestrationSpan, e);
210-
orchestrationScope.close();
219+
if (orchestrationScope != null) orchestrationScope.close();
211220
if (e instanceof Error) {
212221
throw (Error) e;
213222
}
214223
throw new RuntimeException(e);
215224
}
216-
orchestrationScope.close();
225+
if (orchestrationScope != null) orchestrationScope.close();
217226
TracingHelper.endSpan(orchestrationSpan, null);
218227

219228
OrchestratorResponse response = OrchestratorResponse.newBuilder()

client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -298,12 +298,26 @@ public <V> Task<V> callActivity(
298298
if (serializedInput != null) {
299299
scheduleTaskBuilder.setInput(StringValue.of(serializedInput));
300300
}
301-
if (this.parentTraceContext != null) {
302-
scheduleTaskBuilder.setParentTraceContext(this.parentTraceContext);
303-
}
304-
305301
TaskFactory<V> taskFactory = () -> {
306302
int id = this.sequenceNumber++;
303+
304+
// Create a Client-kind span for scheduling (mirrors .NET paired Client+Server spans)
305+
// Only create during non-replay to avoid duplicate spans on orchestration replay
306+
if (this.parentTraceContext != null && !this.isReplaying) {
307+
TraceContext clientCtx = TracingHelper.createClientSpan(
308+
"activity:" + name,
309+
this.parentTraceContext,
310+
TracingHelper.TYPE_ACTIVITY,
311+
name,
312+
this.instanceId,
313+
id);
314+
if (clientCtx != null) {
315+
scheduleTaskBuilder.setParentTraceContext(clientCtx);
316+
}
317+
} else if (this.parentTraceContext != null) {
318+
scheduleTaskBuilder.setParentTraceContext(this.parentTraceContext);
319+
}
320+
307321
this.pendingActions.put(id, OrchestratorAction.newBuilder()
308322
.setId(id)
309323
.setScheduleTask(scheduleTaskBuilder)
@@ -411,10 +425,6 @@ public <V> Task<V> callSubOrchestrator(
411425
}
412426
createSubOrchestrationActionBuilder.setInstanceId(instanceId);
413427

414-
if (this.parentTraceContext != null) {
415-
createSubOrchestrationActionBuilder.setParentTraceContext(this.parentTraceContext);
416-
}
417-
418428
if (options instanceof NewSubOrchestrationInstanceOptions && ((NewSubOrchestrationInstanceOptions)options).getVersion() != null) {
419429
NewSubOrchestrationInstanceOptions subOrchestrationOptions = (NewSubOrchestrationInstanceOptions) options;
420430
createSubOrchestrationActionBuilder.setVersion(StringValue.of(subOrchestrationOptions.getVersion()));
@@ -423,8 +433,28 @@ public <V> Task<V> callSubOrchestrator(
423433
createSubOrchestrationActionBuilder.setVersion(StringValue.of(this.getDefaultVersion()));
424434
}
425435

436+
// Need final copy for lambda capture
437+
final String subInstanceId = instanceId;
426438
TaskFactory<V> taskFactory = () -> {
427439
int id = this.sequenceNumber++;
440+
441+
// Create a Client-kind span for scheduling (mirrors .NET paired Client+Server spans)
442+
// Only create during non-replay to avoid duplicate spans on orchestration replay
443+
if (this.parentTraceContext != null && !this.isReplaying) {
444+
TraceContext clientCtx = TracingHelper.createClientSpan(
445+
"orchestration:" + name,
446+
this.parentTraceContext,
447+
TracingHelper.TYPE_ORCHESTRATION,
448+
name,
449+
subInstanceId,
450+
id);
451+
if (clientCtx != null) {
452+
createSubOrchestrationActionBuilder.setParentTraceContext(clientCtx);
453+
}
454+
} else if (this.parentTraceContext != null) {
455+
createSubOrchestrationActionBuilder.setParentTraceContext(this.parentTraceContext);
456+
}
457+
428458
this.pendingActions.put(id, OrchestratorAction.newBuilder()
429459
.setId(id)
430460
.setCreateSubOrchestration(createSubOrchestrationActionBuilder)

client/src/main/java/com/microsoft/durabletask/TracingHelper.java

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,74 @@ static Span startSpan(
180180
return span;
181181
}
182182

183+
/**
184+
* Creates a short-lived Client-kind span for scheduling an activity or sub-orchestration,
185+
* captures its trace context as a protobuf {@code TraceContext}, and ends the span immediately.
186+
* This mirrors the .NET SDK pattern of paired Client+Server spans.
187+
*
188+
* @param spanName The span name (e.g. "activity:GetWeather").
189+
* @param parentContext The parent trace context from the orchestration, may be {@code null}.
190+
* @param type The durabletask.type value (e.g. "activity").
191+
* @param taskName The task name attribute value.
192+
* @param instanceId The orchestration instance ID.
193+
* @param taskId The task sequence ID.
194+
* @return A {@code TraceContext} captured from the Client span, or the original parentContext if tracing is unavailable.
195+
*/
196+
@Nullable
197+
static TraceContext createClientSpan(
198+
String spanName,
199+
@Nullable TraceContext parentContext,
200+
String type,
201+
String taskName,
202+
@Nullable String instanceId,
203+
int taskId) {
204+
205+
Tracer tracer = GlobalOpenTelemetry.getTracer(TRACER_NAME);
206+
SpanBuilder spanBuilder = tracer.spanBuilder(spanName)
207+
.setSpanKind(SpanKind.CLIENT)
208+
.setAttribute(ATTR_TYPE, type)
209+
.setAttribute(ATTR_TASK_NAME, taskName)
210+
.setAttribute(ATTR_TASK_ID, String.valueOf(taskId));
211+
212+
if (instanceId != null) {
213+
spanBuilder.setAttribute(ATTR_INSTANCE_ID, instanceId);
214+
}
215+
216+
Context parentCtx = extractTraceContext(parentContext);
217+
if (parentCtx != null) {
218+
spanBuilder.setParent(parentCtx);
219+
}
220+
221+
Span clientSpan = spanBuilder.startSpan();
222+
223+
// Capture the client span's context before ending it
224+
TraceContext captured;
225+
try {
226+
SpanContext sc = clientSpan.getSpanContext();
227+
if (sc.isValid()) {
228+
String traceParent = String.format("00-%s-%s-%02x",
229+
sc.getTraceId(), sc.getSpanId(), sc.getTraceFlags().asByte());
230+
231+
String traceState = sc.getTraceState().asMap()
232+
.entrySet()
233+
.stream()
234+
.map(entry -> entry.getKey() + "=" + entry.getValue())
235+
.collect(Collectors.joining(","));
236+
237+
TraceContext.Builder builder = TraceContext.newBuilder().setTraceParent(traceParent);
238+
if (traceState != null && !traceState.isEmpty()) {
239+
builder.setTraceState(StringValue.of(traceState));
240+
}
241+
captured = builder.build();
242+
} else {
243+
captured = parentContext;
244+
}
245+
} finally {
246+
clientSpan.end();
247+
}
248+
return captured;
249+
}
250+
183251
/**
184252
* Ends the given span, optionally recording an error.
185253
*

client/src/test/java/com/microsoft/durabletask/TracingHelperTest.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ public class TracingHelperTest {
3838

3939
@BeforeEach
4040
void setUp() {
41+
// Reset first in case another test class triggered GlobalOpenTelemetry.get()
42+
io.opentelemetry.api.GlobalOpenTelemetry.resetForTest();
4143
spanExporter = InMemorySpanExporter.create();
4244
tracerProvider = SdkTracerProvider.builder()
4345
.addSpanProcessor(SimpleSpanProcessor.create(spanExporter))
@@ -236,4 +238,56 @@ void getCurrentTraceContext_roundTrip() {
236238
assertEquals(originalSpan.getSpanContext().getSpanId(),
237239
extractedSpan.getSpanContext().getSpanId());
238240
}
241+
242+
@Test
243+
void createClientSpan_createsClientKindSpan_withNewSpanId() {
244+
// Create a parent context
245+
Tracer tracer = openTelemetry.getTracer("test");
246+
Span parentSpan = tracer.spanBuilder("parent-orch").startSpan();
247+
TraceContext parentCtx;
248+
try (Scope ignored = parentSpan.makeCurrent()) {
249+
parentCtx = TracingHelper.getCurrentTraceContext();
250+
} finally {
251+
parentSpan.end();
252+
}
253+
assertNotNull(parentCtx);
254+
spanExporter.reset();
255+
256+
// Create a client span
257+
TraceContext clientCtx = TracingHelper.createClientSpan(
258+
"activity:GetWeather", parentCtx,
259+
TracingHelper.TYPE_ACTIVITY, "GetWeather", "instance-123", 3);
260+
261+
assertNotNull(clientCtx);
262+
assertNotNull(clientCtx.getTraceParent());
263+
264+
// Client span should have the same trace ID but different span ID
265+
String parentTraceId = parentCtx.getTraceParent().split("-")[1];
266+
String clientTraceId = clientCtx.getTraceParent().split("-")[1];
267+
String parentSpanId = parentCtx.getTraceParent().split("-")[2];
268+
String clientSpanId = clientCtx.getTraceParent().split("-")[2];
269+
assertEquals(parentTraceId, clientTraceId, "Should share the same trace ID");
270+
assertNotEquals(parentSpanId, clientSpanId, "Should have a new span ID");
271+
272+
// Verify the exported client span has correct attributes and kind
273+
List<SpanData> spans = spanExporter.getFinishedSpanItems();
274+
assertEquals(1, spans.size());
275+
SpanData sd = spans.get(0);
276+
assertEquals("activity:GetWeather", sd.getName());
277+
assertEquals(SpanKind.CLIENT, sd.getKind());
278+
assertEquals("activity", sd.getAttributes().get(io.opentelemetry.api.common.AttributeKey.stringKey("durabletask.type")));
279+
assertEquals("GetWeather", sd.getAttributes().get(io.opentelemetry.api.common.AttributeKey.stringKey("durabletask.task.name")));
280+
assertEquals("instance-123", sd.getAttributes().get(io.opentelemetry.api.common.AttributeKey.stringKey("durabletask.task.instance_id")));
281+
assertEquals("3", sd.getAttributes().get(io.opentelemetry.api.common.AttributeKey.stringKey("durabletask.task.task_id")));
282+
}
283+
284+
@Test
285+
void createClientSpan_withNullParent_returnsNull() {
286+
TraceContext result = TracingHelper.createClientSpan(
287+
"activity:Test", null, TracingHelper.TYPE_ACTIVITY, "Test", null, 0);
288+
// When parent is null, the span has no parent but still creates a valid context
289+
// (or returns null if the tracer returns an invalid span)
290+
// With a registered SDK, it should create a root span
291+
assertNotNull(result);
292+
}
239293
}

samples/README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,13 @@ Shows the trace from `durabletask-java-tracing-sample` service with spans coveri
5757

5858
### Jaeger — Trace Detail
5959

60-
Full span hierarchy showing the fan-out/fan-in pattern:
61-
- `create_orchestration:FanOutFanIn` (parent)
62-
- `orchestration:FanOutFanIn` (orchestration execution)
63-
- `activity:GetWeather` ×5 (parallel fan-out)
64-
- `activity:CreateSummary` (fan-in aggregation)
60+
Full span hierarchy showing the fan-out/fan-in pattern with paired Client+Server spans (matching .NET SDK):
61+
- `create_orchestration:FanOutFanIn` (root, internal)
62+
- `orchestration:FanOutFanIn` (server — orchestration execution)
63+
- `activity:GetWeather` ×5 (client — scheduling) → `activity:GetWeather` ×5 (server — execution)
64+
- `activity:CreateSummary` (client) → `activity:CreateSummary` (server)
65+
66+
14 spans total, Depth 3 — aligned with the .NET SDK trace structure.
6567

6668
![Jaeger trace detail](images/jaeger-full-trace-detail.png)
6769

8.91 KB
Loading
13.6 KB
Loading
14.2 KB
Loading
-28 Bytes
Loading

0 commit comments

Comments
 (0)