Repository: Azure/azure-functions-durable-js
Pull request #639 (2025-05-15 23:57:20)

Distributed Tracing for Entities
This PR adds additional information passing to enable distributed tracing for entities.

- `DurableClient.signalEntity` was modified to pass the current trace context in its HTTP headers (similar to how is done in the `startNew` call) such that the trace for signaling the entity created in WebJobs can correctly be linked as a child of the client trace that triggered the signal entity request.
- The start time for processing an entity invocation is also passed in the `OperationResult` such that the traces can accurately capture the time it takes for the request to be completed (using the `OperationResult.duration` field to calculate the end time).
- In the case of entity A signaling entity B, the id of the `RequestMessage` that led to entity A's invocation is now attached to the `Signal` object, as well as the `requestTime` of the signal request. The former is done so that the span for the signal can be correctly correlated as the child of the span for entity A's invocation (see further discussion in the WebJobs [PR](https://github.com/Azure/azure-functions-durable-extension/pull/3066)). 


Note that for a client signaling an entity via `DurableClient.signalEntity`, in order for this method to be able to obtain the current trace context, the user needs to actually start their own trace from their function app. An example of all the code changes necessary is provided in this [PR ](https://github.com/Azure/azure-functions-durable-js/pull/632) for a client starting an orchestration (the example code would be identical in this case, with `client.startNew` replaced by `client.signalEntity`):

### Client Signaling an Entity
```javascript
app.http('counterHttpAdd', {
    route: `add${entityName}/{id}`,
    extraInputs: [df.input.durableClient()],
    handler: async (req, context) => {
        // Create a new span that carries the incoming trace data.
        const traceparent = context.traceContext.traceParent;
        const tracestate = context.traceContext.traceState;
        const carrier = { traceparent: traceparent, tracestate: tracestate };

        // Extract the parent context
        const parentContext = propagation.extract(otelContext.active(), carrier);
        //const parentContext = propagation.extract(ROOT_CONTEXT, carrier);

        // Create and return a new span using the extracted context.
        const tracer = trace.getTracer('counterHttpAdd');
        const span = tracer.startSpan('myCustomSpan', {
            kind: SpanKind.CLIENT,
        }, parentContext);

        // Mark the span as active
        const activeCtx = trace.setSpan(otelContext.active(), span);
        return await otelContext.with(activeCtx, async () => {
            // Now any spans created inside this callback will automatically use "span"
            // as their parent.
            
            const id = req.params.id;
            const entityId = new df.EntityId(entityName, id);
            const client = df.getClient(context);
    
            // increment value
            await client.signalEntity(entityId, 'add', 1);

            span.end();

            return new HttpResponse({
                jsonBody: 'done',
            });
        });
    },
});
```
where the counter entity looks like

```javascript
df.app.entity(entityName, (context) => {
    const currentValue = context.df.getState(() => 0);
    switch (context.df.operationName) {
        case 'add':
            const amount = context.df.getInput();
            const entityId = new df.EntityId("monitor", "myMonitor");
            context.df.signalEntity(entityId, "milestone-reached", context.df.instanceId);
            context.df.setState(currentValue + amount);
            break;
        case 'reset':
            context.df.setState(0);
            break;
        case 'get':
            context.df.return(currentValue);
            break;
    }
});
```

leads to the following trace, with the counter entity signaling the monitor entity in the `add` operation
![image](https://github.com/user-attachments/assets/858c2a0b-c754-4227-8ca5-dafd62420ede)

### Orchestration Signaling an Entity
Whereas starting the following orchestration 
```javascript
app.http('durableHelloHttpStart', {
    route: 'orchestrators/{orchestratorName}',
    extraInputs: [df.input.durableClient()],
    handler: async (request, context) => {
        // Create a new span that carries the incoming trace data.
        const traceparent = context.traceContext.traceParent;
        const tracestate = context.traceContext.traceState;
        const carrier = { traceparent, tracestate };

        // Extract the parent context
        // const parentContext = propagation.extract(otelContext.active(), carrier);
        const parentContext = propagation.extract(ROOT_CONTEXT, carrier);

        // Create and return a new span using the extracted context.
        const tracer = trace.getTracer('durableHelloHttpStart');
        const span = tracer.startSpan('myCustomSpan', {
            kind: SpanKind.CLIENT,
        }, parentContext);

        // Mark the span as active
        const activeCtx = trace.setSpan(otelContext.active(), span);
        return await otelContext.with(activeCtx, async () => {
            // Now any spans created inside this callback will automatically use "span"
            // as their parent.
            
            // Read the request body.
            const body = await request.text();
            const client = df.getClient(context);

            const instanceId = await client.startNew(request.params.orchestratorName, {
                input: body
            });
            
            context.log(`Started orchestration with ID = '${instanceId}'.`);

            span.end();

            return client.createCheckStatusResponse(request, instanceId);
        });
    },
});

df.app.orchestration('durableCounterOrchestrator', function* (context) {
    const entityId = new df.EntityId(entityName, "myCounter");

    yield context.df.callEntity(entityId, "get");
    context.df.signalEntity(entityId, "add", 1);
});
```

leads to the following trace 
![image](https://github.com/user-attachments/assets/2c9688de-d417-4218-9c89-b4cf18979566)


Patch:
diff --git a/src/durableClient/DurableClient.ts b/src/durableClient/DurableClient.ts
index 1fbed63..f5ffd9f 100644
--- a/src/durableClient/DurableClient.ts
+++ b/src/durableClient/DurableClient.ts
@@ -456,9 +456,12 @@ export class DurableClient implements types.DurableClient {
             );
         }
 
+        const headers = this.getDistributedTracingHeaders();
+
         const response = await this.axiosInstance.post(
             requestUrl,
-            JSON.stringify(operationContent)
+            JSON.stringify(operationContent),
+            { headers }
         );
         switch (response.status) {
             case 202: // signal accepted
diff --git a/src/entities/Entity.ts b/src/entities/Entity.ts
index 65f3f0f..84497a6 100644
--- a/src/entities/Entity.ts
+++ b/src/entities/Entity.ts
@@ -60,13 +60,18 @@ export class Entity<T> {
                 await Promise.resolve(this.fn(context));
                 if (!returnState.results[i]) {
                     const elapsedMs = this.computeElapsedMilliseconds(startTime);
-                    returnState.results[i] = new OperationResult(false, elapsedMs);
+                    returnState.results[i] = new OperationResult(
+                        false,
+                        elapsedMs,
+                        startTime.getTime()
+                    );
                 }
             } catch (error) {
                 const elapsedMs = this.computeElapsedMilliseconds(startTime);
                 returnState.results[i] = new OperationResult(
                     true,
                     elapsedMs,
+                    startTime.getTime(),
                     JSON.stringify(error)
                 );
             }
@@ -95,7 +100,7 @@ export class Entity<T> {
                 value: TResult
             ) => void,
             destructOnExit: this.destructOnExit.bind(this, batchState),
-            signalEntity: this.signalEntity.bind(this, batchState),
+            signalEntity: this.signalEntity.bind(this, batchState, currentRequest),
         };
     }
 
@@ -126,6 +131,7 @@ export class Entity<T> {
             new OperationResult(
                 false,
                 this.computeElapsedMilliseconds(startTime),
+                startTime.getTime(),
                 JSON.stringify(result)
             )
         );
@@ -138,12 +144,19 @@ export class Entity<T> {
 
     private signalEntity(
         returnState: EntityState,
+        request: RequestMessage,
         entity: EntityId,
         operationName: string,
         operationInput?: unknown
     ): void {
         returnState.signals.push(
-            new Signal(entity, operationName, operationInput ? JSON.stringify(operationInput) : "")
+            new Signal(
+                entity,
+                operationName,
+                operationInput ? JSON.stringify(operationInput) : "",
+                request.id,
+                new Date().getTime()
+            )
         );
     }
 
diff --git a/src/entities/OperationResult.ts b/src/entities/OperationResult.ts
index 2153e8a..47bd31a 100644
--- a/src/entities/OperationResult.ts
+++ b/src/entities/OperationResult.ts
@@ -1,4 +1,9 @@
 /** @hidden */
 export class OperationResult {
-    constructor(readonly isError: boolean, readonly duration: number, readonly result?: string) {}
+    constructor(
+        readonly isError: boolean,
+        readonly duration: number,
+        readonly startTime: number,
+        readonly result?: string
+    ) {}
 }
diff --git a/src/entities/Signal.ts b/src/entities/Signal.ts
index 18706a7..d87c550 100644
--- a/src/entities/Signal.ts
+++ b/src/entities/Signal.ts
@@ -5,6 +5,8 @@ export class Signal {
     constructor(
         public readonly target: EntityId,
         public readonly name: string,
-        public readonly input: string
+        public readonly input: string,
+        public readonly requestId: string,
+        public readonly requestTime: number
     ) {}
 }


Test patch:
diff --git a/test/testobjects/testentitybatches.ts b/test/testobjects/testentitybatches.ts
index 420baeb..c3f4bb9 100644
--- a/test/testobjects/testentitybatches.ts
+++ b/test/testobjects/testentitybatches.ts
@@ -38,6 +38,7 @@ export class TestEntityBatches {
                     output.results[operationCount] = new OperationResult(
                         false,
                         -1,
+                        -1,
                         output.entityState
                     );
                     break;
@@ -50,7 +51,7 @@ export class TestEntityBatches {
                     batch[operationCount].input = value;
 
                     // Handle outputs
-                    output.results[operationCount] = new OperationResult(false, -1);
+                    output.results[operationCount] = new OperationResult(false, -1, -1);
                     output.entityExists = true;
                     output.entityState = value;
                     break;
@@ -96,6 +97,7 @@ export class TestEntityBatches {
                     output.results[operationCount] = new OperationResult(
                         false,
                         -1,
+                        -1,
                         JSON.stringify(currentState)
                     );
                     break;
@@ -108,7 +110,7 @@ export class TestEntityBatches {
 
                     // Handle outputs
                     currentState = operation.value;
-                    output.results[operationCount] = new OperationResult(false, -1);
+                    output.results[operationCount] = new OperationResult(false, -1, -1);
                     output.entityExists = true;
                     output.entityState = currentState.toString();
                     break;
@@ -119,13 +121,14 @@ export class TestEntityBatches {
 
                     if (currentState != null) {
                         currentState = currentState + 1;
-                        output.results[operationCount] = new OperationResult(false, -1);
+                        output.results[operationCount] = new OperationResult(false, -1, -1);
                         output.entityExists = true;
                         output.entityState = currentState.toString();
                     } else {
                         output.results[operationCount] = new OperationResult(
                             true,
                             -1,
+                            -1,
                             "dummy error message"
                         );
                     }
@@ -138,13 +141,14 @@ export class TestEntityBatches {
 
                     if (currentState != null) {
                         currentState = currentState + operation.value;
-                        output.results[operationCount] = new OperationResult(false, -1);
+                        output.results[operationCount] = new OperationResult(false, -1, -1);
                         output.entityExists = true;
                         output.entityState = currentState.toString();
                     } else {
                         output.results[operationCount] = new OperationResult(
                             true,
                             -1,
+                            -1,
                             "dummy error message"
                         );
                     }
@@ -155,7 +159,7 @@ export class TestEntityBatches {
                     batch[operationCount].signal = false;
 
                     output.entityExists = false;
-                    output.results[operationCount] = new OperationResult(false, -1);
+                    output.results[operationCount] = new OperationResult(false, -1, -1);
                     break;
             }
             operationCount++;
@@ -199,6 +203,7 @@ export class TestEntityBatches {
                     output.results[operationCount] = new OperationResult(
                         false,
                         -1,
+                        -1,
                         output.entityState
                     );
                     break;
@@ -211,7 +216,7 @@ export class TestEntityBatches {
                     batch[operationCount].input = value;
 
                     // Handle outputs
-                    output.results[operationCount] = new OperationResult(false, -1);
+                    output.results[operationCount] = new OperationResult(false, -1, -1);
                     output.entityExists = true;
                     output.entityState = value;
                     break;