From c0fa63f0d9bbab3fdc6caeb1967c9da2fa69ccc6 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 30 Oct 2026 15:01:55 -0400 Subject: [PATCH 1/7] .NET: align workflow span name with OpenTelemetry conventions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa592ce6-05c6-4421-801b-59c77df4b2df --- .../Observability/ActivityNames.cs | 2 +- .../Observability/WorkflowTelemetryOptions.cs | 2 +- .../DeclarativeWorkflowOptionsTest.cs | 2 +- .../ObservabilityTests.cs | 35 +++++++++++++++++++ .../WorkflowRunActivityStopTests.cs | 24 ++++++------- 5 files changed, 50 insertions(+), 15 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/ActivityNames.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/ActivityNames.cs index 1639fc3c3cb..533184330c9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/ActivityNames.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/ActivityNames.cs @@ -6,7 +6,7 @@ internal static class ActivityNames { public const string WorkflowBuild = "workflow.build"; public const string WorkflowSession = "workflow.session"; - public const string WorkflowInvoke = "workflow_invoke"; + public const string WorkflowInvoke = "invoke_workflow"; public const string MessageSend = "message.send"; public const string ExecutorProcess = "executor.process"; public const string EdgeGroupProcess = "edge_group.process"; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryOptions.cs index b32f0c0f669..80f637a4503 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryOptions.cs @@ -34,7 +34,7 @@ public sealed class WorkflowTelemetryOptions /// Gets or sets a value indicating whether workflow run activities should be disabled. /// /// - /// to disable workflow_invoke activities; + /// to disable invoke_workflow activities; /// to enable them. The default value is . /// public bool DisableWorkflowRun { get; set; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowOptionsTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowOptionsTest.cs index 09e2e393d49..e032052238a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowOptionsTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowOptionsTest.cs @@ -29,7 +29,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable // These constants mirror Microsoft.Agents.AI.Workflows.Observability.ActivityNames // which is internal and not accessible from this test project. private const string WorkflowBuildActivityName = "workflow.build"; - private const string WorkflowRunActivityName = "workflow_invoke"; + private const string WorkflowRunActivityName = "invoke_workflow"; // The default activity source name used by the workflow telemetry context. private const string DefaultTelemetrySourceName = "Microsoft.Agents.AI.Workflows"; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs index 82723d02871..89fddd9525e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs @@ -158,6 +158,41 @@ public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_LockstepAsyn await this.TestWorkflowEndToEndActivitiesAsync("Lockstep"); } + [Theory] + [InlineData("Lockstep", false)] + [InlineData("Lockstep", true)] + [InlineData("OffThread", false)] + [InlineData("OffThread", true)] + [InlineData("Concurrent", false)] + [InlineData("Concurrent", true)] + public async Task WorkflowRunActivity_UsesSemanticConventionNameAsync(string executionEnvironmentName, bool streaming) + { + // Arrange + using var testActivity = new Activity("WorkflowRunSpanNameTest").Start(); + var workflow = CreateWorkflow(); + var executionEnvironment = GetExecutionEnvironment(executionEnvironmentName); + + // Act + if (streaming) + { + await using StreamingRun run = await executionEnvironment.RunStreamingAsync(workflow, "Hello, World!"); + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + // Drain the stream to complete the workflow invocation. + } + } + else + { + await using Run run = await executionEnvironment.RunAsync(workflow, "Hello, World!"); + } + + // Assert + var workflowRunActivity = Assert.Single(this._capturedActivities, a => + a.RootId == testActivity.RootId && + a.Events.Any(e => e.Name == EventNames.WorkflowStarted)); + Assert.Equal("invoke_workflow", workflowRunActivity.OperationName); + } + [Fact] public async Task CreatesWorkflowActivities_WithCorrectNameAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowRunActivityStopTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowRunActivityStopTests.cs index f70c9294d5c..934fb68c6b0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowRunActivityStopTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowRunActivityStopTests.cs @@ -11,8 +11,8 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests; /// /// Regression test for https://github.com/microsoft/agent-framework/issues/4155 -/// Verifies that the workflow_invoke Activity is properly stopped/disposed so it gets exported -/// to telemetry backends. The ActivityStopped callback must fire for the workflow_invoke span. +/// Verifies that the invoke_workflow Activity is properly stopped/disposed so it gets exported +/// to telemetry backends. The ActivityStopped callback must fire for the invoke_workflow span. /// [Collection("ObservabilityTests")] public sealed class WorkflowRunActivityStopTests : IDisposable @@ -61,7 +61,7 @@ private static Workflow CreateWorkflow() } /// - /// Verifies that the workflow_invoke Activity is stopped (and thus exportable) when + /// Verifies that the invoke_workflow Activity is stopped (and thus exportable) when /// using the Lockstep execution environment. /// Bug: The Activity created by LockstepRunEventStream.TakeEventStreamAsync is never /// disposed because yield break in async iterators does not trigger using disposal. @@ -90,7 +90,7 @@ public async Task WorkflowRunActivity_IsStopped_LockstepAsync() .ToList(); Assert.Single(stoppedSessions ?? []); - // Assert - workflow_invoke should have been started and stopped + // Assert - invoke_workflow should have been started and stopped var startedWorkflowRuns = this._startedActivities .Where(a => a.RootId == testActivity.RootId && a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)) @@ -105,7 +105,7 @@ public async Task WorkflowRunActivity_IsStopped_LockstepAsync() } /// - /// Verifies that the workflow_invoke Activity is stopped when using the OffThread (Default) + /// Verifies that the invoke_workflow Activity is stopped when using the OffThread (Default) /// execution environment (StreamingRunEventStream). /// [Fact] @@ -132,7 +132,7 @@ public async Task WorkflowRunActivity_IsStopped_OffThreadAsync() .ToList(); Assert.Single(stoppedSessions ?? []); - // Assert - workflow_invoke should have been started and stopped + // Assert - invoke_workflow should have been started and stopped var startedWorkflowRuns = this._startedActivities .Where(a => a.RootId == testActivity.RootId && a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)) @@ -147,7 +147,7 @@ public async Task WorkflowRunActivity_IsStopped_OffThreadAsync() } /// - /// Verifies that the workflow_invoke Activity is stopped when using the streaming API + /// Verifies that the invoke_workflow Activity is stopped when using the streaming API /// (StreamingRun.WatchStreamAsync) with the OffThread execution environment. /// This matches the exact usage pattern described in the issue. /// @@ -177,14 +177,14 @@ public async Task WorkflowRunActivity_IsStopped_Streaming_OffThreadAsync() .ToList(); Assert.Single(startedSessions ?? []); - // Assert - workflow_invoke should have been started + // Assert - invoke_workflow should have been started var startedWorkflowRuns = this._startedActivities .Where(a => a.RootId == testActivity.RootId && a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)) .ToList(); Assert.Single(startedWorkflowRuns ?? []); - // Assert - workflow_invoke should have been stopped + // Assert - invoke_workflow should have been stopped var stoppedWorkflowRuns = this._stoppedActivities .Where(a => a.RootId == testActivity.RootId && a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)) @@ -193,7 +193,7 @@ public async Task WorkflowRunActivity_IsStopped_Streaming_OffThreadAsync() } /// - /// Verifies that a new workflow_invoke activity is started and stopped for each + /// Verifies that a new invoke_workflow activity is started and stopped for each /// streaming invocation, even when using the same workflow in a multi-turn pattern, /// and that each session gets its own session activity. /// @@ -236,7 +236,7 @@ public async Task WorkflowRunActivity_IsStopped_Streaming_OffThread_MultiTurnAsy .ToList(); Assert.Equal(2, stoppedSessions.Count); - // Assert - two workflow_invoke activities should have been started and stopped + // Assert - two invoke_workflow activities should have been started and stopped var startedWorkflowRuns = this._startedActivities .Where(a => a.RootId == testActivity.RootId && a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)) @@ -251,7 +251,7 @@ public async Task WorkflowRunActivity_IsStopped_Streaming_OffThread_MultiTurnAsy } /// - /// Verifies that all started activities (not just workflow_invoke) are properly stopped. + /// Verifies that all started activities (not just invoke_workflow) are properly stopped. /// This ensures no spans are "leaked" without being exported. /// [Fact] From dca4f138539908546ceeac0e08d3b8029675455e Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 2 Nov 2026 11:01:23 -0500 Subject: [PATCH 2/7] .NET: align workflow name telemetry attribute with OpenTelemetry Record application-defined workflow names on invocation spans at creation, replace the legacy build-span key, and omit blank names. Cover named and unnamed workflows across execution modes and document the attribute migration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa4dae7a-fdbd-475c-8ec6-fa8333e77cc4 --- .../Execution/ISuperStepRunner.cs | 2 + .../Execution/LockstepRunEventStream.cs | 2 +- .../Execution/StreamingRunEventStream.cs | 2 +- .../InProc/InProcessRunner.cs | 3 + .../Observability/Tags.cs | 2 +- .../Observability/WorkflowTelemetryContext.cs | 11 +++- .../WorkflowBuilder.cs | 2 +- .../ObservabilityTests.cs | 57 +++++++++++++++---- .../references/observability.md | 2 + 9 files changed, 66 insertions(+), 17 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs index ea535266042..fa894b672c7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs @@ -13,6 +13,8 @@ internal interface ISuperStepRunner string StartExecutorId { get; } + string? WorkflowName { get; } + WorkflowTelemetryContext TelemetryContext { get; } bool HasUnservicedRequests { get; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs index cdd8cc7686e..ee39b0fbbc6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs @@ -63,7 +63,7 @@ public async IAsyncEnumerable TakeEventStreamAsync(bool blockOnPe Activity.Current = this._sessionActivity; // Not 'using' — must dispose explicitly in finally for deterministic export. - Activity? runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity(); + Activity? runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity(this._stepRunner.WorkflowName); runActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.SessionId, this._stepRunner.SessionId); try diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs index f94bd9848d7..66bd95a56e6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs @@ -87,7 +87,7 @@ private async Task RunLoopAsync(CancellationToken cancellationToken) while (!linkedSource.Token.IsCancellationRequested) { // Start a new run-stage activity for this input→processing→halt cycle - runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity(); + runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity(this._stepRunner.WorkflowName); runActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId) .SetTag(Tags.SessionId, this._stepRunner.SessionId); runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted)); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index a8ec8bb042d..63c902a0787 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -68,6 +68,9 @@ private InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager /// public string StartExecutorId { get; } + /// + public string? WorkflowName => this.Workflow.Name; + /// /// Gating flag for deferred event republishing after checkpoint restore. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs index 47ce7017944..a4428a1aee3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs @@ -5,7 +5,7 @@ namespace Microsoft.Agents.AI.Workflows.Observability; internal static class Tags { public const string WorkflowId = "workflow.id"; - public const string WorkflowName = "workflow.name"; + public const string WorkflowName = "gen_ai.workflow.name"; public const string WorkflowDescription = "workflow.description"; public const string WorkflowDefinition = "workflow.definition"; public const string BuildErrorMessage = "build.error.message"; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs index a55aad9f4ad..2e5bb2864c8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs @@ -109,15 +109,22 @@ public WorkflowTelemetryContext(WorkflowTelemetryOptions options, ActivitySource /// Starts a workflow run activity if enabled. This represents a single /// input-to-halt cycle within a workflow session. /// + /// The optional application-defined workflow name. /// An activity if workflow run telemetry is enabled, otherwise null. - public Activity? StartWorkflowRunActivity() + public Activity? StartWorkflowRunActivity(string? workflowName) { if (!this.IsEnabled || this.Options.DisableWorkflowRun) { return null; } - return this.ActivitySource.StartActivity(ActivityNames.WorkflowInvoke); + ActivityTagsCollection? tags = null; + if (!string.IsNullOrWhiteSpace(workflowName)) + { + tags = new() { { Tags.WorkflowName, workflowName } }; + } + + return this.ActivitySource.StartActivity(ActivityNames.WorkflowInvoke, ActivityKind.Internal, parentContext: default, tags: tags); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index 1f6ae184054..6781690a232 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -643,7 +643,7 @@ private Workflow BuildInternal(bool validateOrphans, Activity? activity = null) // Using the start executor ID as a proxy for the workflow ID activity?.SetTag(Tags.WorkflowId, workflow.StartExecutorId); - if (workflow.Name is not null) + if (!string.IsNullOrWhiteSpace(workflow.Name)) { activity?.SetTag(Tags.WorkflowName, workflow.Name); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs index 89fddd9525e..17bdcea0656 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs @@ -56,7 +56,7 @@ public ObservabilityTests() /// ---- ActivityNames.MessageSend /// /// The created workflow. - private static Workflow CreateWorkflow() + private static Workflow CreateWorkflow(string? name = null) { // Create the executors Func uppercaseFunc = s => s.ToUpperInvariant(); @@ -68,6 +68,10 @@ private static Workflow CreateWorkflow() // Build the workflow by connecting executors sequentially WorkflowBuilder builder = new(uppercase); builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse); + if (name is not null) + { + builder.WithName(name); + } return builder.WithOpenTelemetry().Build(); } @@ -159,17 +163,23 @@ public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_LockstepAsyn } [Theory] - [InlineData("Lockstep", false)] - [InlineData("Lockstep", true)] - [InlineData("OffThread", false)] - [InlineData("OffThread", true)] - [InlineData("Concurrent", false)] - [InlineData("Concurrent", true)] - public async Task WorkflowRunActivity_UsesSemanticConventionNameAsync(string executionEnvironmentName, bool streaming) + [InlineData("Lockstep", false, null)] + [InlineData("Lockstep", true, null)] + [InlineData("OffThread", false, null)] + [InlineData("OffThread", true, null)] + [InlineData("Concurrent", false, null)] + [InlineData("Concurrent", true, null)] + [InlineData("Lockstep", false, "customer_support_pipeline")] + [InlineData("Lockstep", true, "customer_support_pipeline")] + [InlineData("OffThread", false, "customer_support_pipeline")] + [InlineData("OffThread", true, "customer_support_pipeline")] + [InlineData("Concurrent", false, "customer_support_pipeline")] + [InlineData("Concurrent", true, "customer_support_pipeline")] + public async Task WorkflowRunActivity_UsesSemanticConventionNameAsync(string executionEnvironmentName, bool streaming, string? workflowName) { // Arrange using var testActivity = new Activity("WorkflowRunSpanNameTest").Start(); - var workflow = CreateWorkflow(); + var workflow = CreateWorkflow(workflowName); var executionEnvironment = GetExecutionEnvironment(executionEnvironmentName); // Act @@ -187,10 +197,35 @@ public async Task WorkflowRunActivity_UsesSemanticConventionNameAsync(string exe } // Assert - var workflowRunActivity = Assert.Single(this._capturedActivities, a => - a.RootId == testActivity.RootId && + var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); + var workflowRunActivity = Assert.Single(capturedActivities, a => a.Events.Any(e => e.Name == EventNames.WorkflowStarted)); Assert.Equal("invoke_workflow", workflowRunActivity.OperationName); + Assert.Equal(workflowName, workflowRunActivity.GetTagItem("gen_ai.workflow.name")); + + var workflowBuildActivity = Assert.Single(capturedActivities, a => a.OperationName == ActivityNames.WorkflowBuild); + Assert.Equal(workflowName, workflowBuildActivity.GetTagItem("gen_ai.workflow.name")); + Assert.All(capturedActivities, a => Assert.DoesNotContain(a.TagObjects, tag => tag.Key == "workflow.name")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task WorkflowActivities_WithoutMeaningfulName_OmitWorkflowNameAsync(string workflowName) + { + // Arrange + using var testActivity = new Activity("WorkflowNameOmissionTest").Start(); + var workflow = CreateWorkflow(workflowName); + + // Act + Run run = await InProcessExecution.Default.RunAsync(workflow, "Hello, World!"); + await run.DisposeAsync(); + + // Assert + var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); + Assert.Contains(capturedActivities, a => a.OperationName == ActivityNames.WorkflowInvoke); + Assert.All(capturedActivities, a => Assert.DoesNotContain(a.TagObjects, + tag => tag.Key is "gen_ai.workflow.name" or "workflow.name")); } [Fact] diff --git a/skills/agent-framework-builder-dotnet/references/observability.md b/skills/agent-framework-builder-dotnet/references/observability.md index 8ad331aebcb..0c4f90f561f 100644 --- a/skills/agent-framework-builder-dotnet/references/observability.md +++ b/skills/agent-framework-builder-dotnet/references/observability.md @@ -15,6 +15,8 @@ runtime evidence; it does not determine whether output is correct. a generic host. - Keep sensitive payload capture (`EnableSensitiveData`) disabled unless the application explicitly accepts the exposure of prompts, responses, and tool arguments. +- Name workflows with `WorkflowBuilder.WithName(...)`. Workflow build and invocation activities record this name as + `gen_ai.workflow.name` (replacing `workflow.name`); the attribute is omitted when no meaningful name is configured. - Redaction does not replace sanitization in custom telemetry, exporters, middleware, logs (`UseLogging`), or exception messages. From fef96756f37ee35b3cfba964fb9a9b24ec913a4e Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 2 Nov 2026 11:04:22 -0500 Subject: [PATCH 3/7] .NET: record workflow invocation operation attribute Set gen_ai.operation.name to invoke_workflow at activity creation for sampling. Verify named and unnamed workflows across execution modes, creation-time sampling, and omission from unrelated activities. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa4dae7a-fdbd-475c-8ec6-fa8333e77cc4 --- .../Observability/Tags.cs | 1 + .../Observability/WorkflowTelemetryContext.cs | 4 +-- .../ObservabilityTests.cs | 31 +++++++++++++++++++ .../references/observability.md | 1 + 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs index a4428a1aee3..4c070e8dd31 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs @@ -4,6 +4,7 @@ namespace Microsoft.Agents.AI.Workflows.Observability; internal static class Tags { + public const string OperationName = "gen_ai.operation.name"; public const string WorkflowId = "workflow.id"; public const string WorkflowName = "gen_ai.workflow.name"; public const string WorkflowDescription = "workflow.description"; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs index 2e5bb2864c8..4e9d5ca10f0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs @@ -118,10 +118,10 @@ public WorkflowTelemetryContext(WorkflowTelemetryOptions options, ActivitySource return null; } - ActivityTagsCollection? tags = null; + ActivityTagsCollection tags = new() { { Tags.OperationName, ActivityNames.WorkflowInvoke } }; if (!string.IsNullOrWhiteSpace(workflowName)) { - tags = new() { { Tags.WorkflowName, workflowName } }; + tags.Add(Tags.WorkflowName, workflowName); } return this.ActivitySource.StartActivity(ActivityNames.WorkflowInvoke, ActivityKind.Internal, parentContext: default, tags: tags); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs index 17bdcea0656..6943e1ed8c8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs @@ -202,12 +202,43 @@ public async Task WorkflowRunActivity_UsesSemanticConventionNameAsync(string exe a.Events.Any(e => e.Name == EventNames.WorkflowStarted)); Assert.Equal("invoke_workflow", workflowRunActivity.OperationName); Assert.Equal(workflowName, workflowRunActivity.GetTagItem("gen_ai.workflow.name")); + Assert.Equal("invoke_workflow", workflowRunActivity.GetTagItem("gen_ai.operation.name")); + Assert.All(capturedActivities.Where(a => a != workflowRunActivity), a => + Assert.DoesNotContain(a.TagObjects, tag => tag.Key == "gen_ai.operation.name")); var workflowBuildActivity = Assert.Single(capturedActivities, a => a.OperationName == ActivityNames.WorkflowBuild); Assert.Equal(workflowName, workflowBuildActivity.GetTagItem("gen_ai.workflow.name")); Assert.All(capturedActivities, a => Assert.DoesNotContain(a.TagObjects, tag => tag.Key == "workflow.name")); } + [Theory] + [InlineData(null)] + [InlineData("customer_support_pipeline")] + public void WorkflowRunActivity_ProvidesOperationNameAtCreation(string? workflowName) + { + // Arrange + using var activitySource = new ActivitySource("WorkflowOperationNameSamplingTest"); + object? operationNameAtCreation = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source == activitySource, + Sample = (ref ActivityCreationOptions options) => + { + operationNameAtCreation = options.Tags?.FirstOrDefault(tag => tag.Key == "gen_ai.operation.name").Value; + return ActivitySamplingResult.AllData; + }, + }; + ActivitySource.AddActivityListener(listener); + var telemetryContext = new WorkflowTelemetryContext(new WorkflowTelemetryOptions(), activitySource); + + // Act + using Activity? activity = telemetryContext.StartWorkflowRunActivity(workflowName); + + // Assert + Assert.NotNull(activity); + Assert.Equal("invoke_workflow", operationNameAtCreation); + } + [Theory] [InlineData("")] [InlineData(" ")] diff --git a/skills/agent-framework-builder-dotnet/references/observability.md b/skills/agent-framework-builder-dotnet/references/observability.md index 0c4f90f561f..aad35dab1c2 100644 --- a/skills/agent-framework-builder-dotnet/references/observability.md +++ b/skills/agent-framework-builder-dotnet/references/observability.md @@ -17,6 +17,7 @@ runtime evidence; it does not determine whether output is correct. exposure of prompts, responses, and tool arguments. - Name workflows with `WorkflowBuilder.WithName(...)`. Workflow build and invocation activities record this name as `gen_ai.workflow.name` (replacing `workflow.name`); the attribute is omitted when no meaningful name is configured. +- Workflow invocation activities include `gen_ai.operation.name=invoke_workflow` at creation so sampling can use it. - Redaction does not replace sanitization in custom telemetry, exporters, middleware, logs (`UseLogging`), or exception messages. From 9b1396789a66c5579da2dbcec6452f13adcec7cc Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 2 Nov 2026 11:07:48 -0500 Subject: [PATCH 4/7] .NET: include workflow names in invocation spans Append meaningful workflow names at activity creation while preserving the base operation attribute and unnamed fallback. Cover sampling, streaming and non-streaming execution, and named-span lifecycle and parenting. Update telemetry documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa4dae7a-fdbd-475c-8ec6-fa8333e77cc4 --- .../Observability/WorkflowTelemetryContext.cs | 4 +++- .../Observability/WorkflowTelemetryOptions.cs | 3 ++- .../ObservabilityTests.cs | 15 +++++++++++---- .../WorkflowRunActivityStopTests.cs | 2 +- .../references/observability.md | 4 +++- 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs index 4e9d5ca10f0..0f6b7342ed8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs @@ -119,12 +119,14 @@ public WorkflowTelemetryContext(WorkflowTelemetryOptions options, ActivitySource } ActivityTagsCollection tags = new() { { Tags.OperationName, ActivityNames.WorkflowInvoke } }; + string activityName = ActivityNames.WorkflowInvoke; if (!string.IsNullOrWhiteSpace(workflowName)) { tags.Add(Tags.WorkflowName, workflowName); + activityName += " " + workflowName; } - return this.ActivitySource.StartActivity(ActivityNames.WorkflowInvoke, ActivityKind.Internal, parentContext: default, tags: tags); + return this.ActivitySource.StartActivity(activityName, ActivityKind.Internal, parentContext: default, tags: tags); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryOptions.cs index 80f637a4503..17b9af54d9f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryOptions.cs @@ -34,7 +34,8 @@ public sealed class WorkflowTelemetryOptions /// Gets or sets a value indicating whether workflow run activities should be disabled. /// /// - /// to disable invoke_workflow activities; + /// to disable invoke_workflow {gen_ai.workflow.name} activities + /// (or invoke_workflow when unnamed); /// to enable them. The default value is . /// public bool DisableWorkflowRun { get; set; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs index 6943e1ed8c8..5bb4f18384a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs @@ -200,7 +200,9 @@ public async Task WorkflowRunActivity_UsesSemanticConventionNameAsync(string exe var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); var workflowRunActivity = Assert.Single(capturedActivities, a => a.Events.Any(e => e.Name == EventNames.WorkflowStarted)); - Assert.Equal("invoke_workflow", workflowRunActivity.OperationName); + string expectedSpanName = workflowName is null ? "invoke_workflow" : $"invoke_workflow {workflowName}"; + Assert.Equal(expectedSpanName, workflowRunActivity.OperationName); + Assert.Equal(expectedSpanName, workflowRunActivity.DisplayName); Assert.Equal(workflowName, workflowRunActivity.GetTagItem("gen_ai.workflow.name")); Assert.Equal("invoke_workflow", workflowRunActivity.GetTagItem("gen_ai.operation.name")); Assert.All(capturedActivities.Where(a => a != workflowRunActivity), a => @@ -212,18 +214,22 @@ public async Task WorkflowRunActivity_UsesSemanticConventionNameAsync(string exe } [Theory] - [InlineData(null)] - [InlineData("customer_support_pipeline")] - public void WorkflowRunActivity_ProvidesOperationNameAtCreation(string? workflowName) + [InlineData(null, "invoke_workflow")] + [InlineData("", "invoke_workflow")] + [InlineData(" ", "invoke_workflow")] + [InlineData("customer_support_pipeline", "invoke_workflow customer_support_pipeline")] + public void WorkflowRunActivity_ProvidesSemanticConventionsAtCreation(string? workflowName, string expectedSpanName) { // Arrange using var activitySource = new ActivitySource("WorkflowOperationNameSamplingTest"); + string? spanNameAtCreation = null; object? operationNameAtCreation = null; using var listener = new ActivityListener { ShouldListenTo = source => source == activitySource, Sample = (ref ActivityCreationOptions options) => { + spanNameAtCreation = options.Name; operationNameAtCreation = options.Tags?.FirstOrDefault(tag => tag.Key == "gen_ai.operation.name").Value; return ActivitySamplingResult.AllData; }, @@ -236,6 +242,7 @@ public void WorkflowRunActivity_ProvidesOperationNameAtCreation(string? workflow // Assert Assert.NotNull(activity); + Assert.Equal(expectedSpanName, spanNameAtCreation); Assert.Equal("invoke_workflow", operationNameAtCreation); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowRunActivityStopTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowRunActivityStopTests.cs index 934fb68c6b0..6fc019c84cc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowRunActivityStopTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowRunActivityStopTests.cs @@ -57,7 +57,7 @@ private static Workflow CreateWorkflow() WorkflowBuilder builder = new(uppercase); builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse); - return builder.WithOpenTelemetry().Build(); + return builder.WithName("activity_stop_workflow").WithOpenTelemetry().Build(); } /// diff --git a/skills/agent-framework-builder-dotnet/references/observability.md b/skills/agent-framework-builder-dotnet/references/observability.md index aad35dab1c2..03687fe03c2 100644 --- a/skills/agent-framework-builder-dotnet/references/observability.md +++ b/skills/agent-framework-builder-dotnet/references/observability.md @@ -17,7 +17,9 @@ runtime evidence; it does not determine whether output is correct. exposure of prompts, responses, and tool arguments. - Name workflows with `WorkflowBuilder.WithName(...)`. Workflow build and invocation activities record this name as `gen_ai.workflow.name` (replacing `workflow.name`); the attribute is omitted when no meaningful name is configured. -- Workflow invocation activities include `gen_ai.operation.name=invoke_workflow` at creation so sampling can use it. +- Workflow invocation activities are named `invoke_workflow {gen_ai.workflow.name}`, or `invoke_workflow` when no + meaningful workflow name exists. `gen_ai.operation.name` remains `invoke_workflow` and is available at creation for + sampling. - Redaction does not replace sanitization in custom telemetry, exporters, middleware, logs (`UseLogging`), or exception messages. From 2952799d618703ab567f9e081134cd401a66b999 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 2 Nov 2026 11:40:52 -0500 Subject: [PATCH 5/7] .NET: propagate supplied workflow conversation context Record gen_ai.conversation.id from OpenTelemetry or Activity baggage at invocation activity creation, with no execution-session fallback. Cover context precedence, named and unnamed workflows, streaming and non-streaming execution, and omission without context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa4dae7a-fdbd-475c-8ec6-fa8333e77cc4 --- .../Observability/Tags.cs | 1 + .../Observability/WorkflowTelemetryContext.cs | 11 +++ .../ObservabilityTests.cs | 88 ++++++++++++++++++- .../references/observability.md | 4 + 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs index 4c070e8dd31..151d15137e6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs @@ -5,6 +5,7 @@ namespace Microsoft.Agents.AI.Workflows.Observability; internal static class Tags { public const string OperationName = "gen_ai.operation.name"; + public const string ConversationId = "gen_ai.conversation.id"; public const string WorkflowId = "workflow.id"; public const string WorkflowName = "gen_ai.workflow.name"; public const string WorkflowDescription = "workflow.description"; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs index 0f6b7342ed8..7794eb4f1a7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using OpenTelemetry; namespace Microsoft.Agents.AI.Workflows.Observability; @@ -126,6 +127,16 @@ public WorkflowTelemetryContext(WorkflowTelemetryOptions options, ActivitySource activityName += " " + workflowName; } + string? conversationId = Baggage.GetBaggage(Tags.ConversationId); + if (string.IsNullOrWhiteSpace(conversationId)) + { + conversationId = Activity.Current?.GetBaggageItem(Tags.ConversationId); + } + if (!string.IsNullOrWhiteSpace(conversationId)) + { + tags.Add(Tags.ConversationId, conversationId); + } + return this.ActivitySource.StartActivity(activityName, ActivityKind.Internal, parentContext: default, tags: tags); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs index 5bb4f18384a..308ab5ba1f6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs @@ -10,6 +10,7 @@ using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Agents.AI.Workflows.Observability; using Microsoft.Extensions.AI; +using OpenTelemetry; namespace Microsoft.Agents.AI.Workflows.UnitTests; @@ -179,6 +180,7 @@ public async Task WorkflowRunActivity_UsesSemanticConventionNameAsync(string exe { // Arrange using var testActivity = new Activity("WorkflowRunSpanNameTest").Start(); + testActivity.AddBaggage("gen_ai.conversation.id", "application-thread"); var workflow = CreateWorkflow(workflowName); var executionEnvironment = GetExecutionEnvironment(executionEnvironmentName); @@ -205,8 +207,10 @@ public async Task WorkflowRunActivity_UsesSemanticConventionNameAsync(string exe Assert.Equal(expectedSpanName, workflowRunActivity.DisplayName); Assert.Equal(workflowName, workflowRunActivity.GetTagItem("gen_ai.workflow.name")); Assert.Equal("invoke_workflow", workflowRunActivity.GetTagItem("gen_ai.operation.name")); + Assert.Equal("application-thread", workflowRunActivity.GetTagItem("gen_ai.conversation.id")); + Assert.IsType(workflowRunActivity.GetTagItem("session.id")); Assert.All(capturedActivities.Where(a => a != workflowRunActivity), a => - Assert.DoesNotContain(a.TagObjects, tag => tag.Key == "gen_ai.operation.name")); + Assert.DoesNotContain(a.TagObjects, tag => tag.Key is "gen_ai.operation.name" or "gen_ai.conversation.id")); var workflowBuildActivity = Assert.Single(capturedActivities, a => a.OperationName == ActivityNames.WorkflowBuild); Assert.Equal(workflowName, workflowBuildActivity.GetTagItem("gen_ai.workflow.name")); @@ -246,6 +250,88 @@ public void WorkflowRunActivity_ProvidesSemanticConventionsAtCreation(string? wo Assert.Equal("invoke_workflow", operationNameAtCreation); } + [Theory] + [InlineData(null, null, null)] + [InlineData("", null, null)] + [InlineData(" ", null, null)] + [InlineData("otel-thread", null, "otel-thread")] + [InlineData("otel-thread", "activity-thread", "otel-thread")] + [InlineData(null, "activity-thread", "activity-thread")] + [InlineData("", "activity-thread", "activity-thread")] + [InlineData(" ", "activity-thread", "activity-thread")] + public void WorkflowRunActivity_UsesConversationContextAtCreation( + string? otelConversationId, string? activityConversationId, string? expectedConversationId) + { + // Arrange + using var testActivity = new Activity("WorkflowConversationContextTest").Start(); + testActivity.AddBaggage("gen_ai.conversation.id", activityConversationId); + using var activitySource = new ActivitySource("WorkflowConversationSamplingTest"); + object? conversationIdAtCreation = null; + using var listener = new ActivityListener + { + ShouldListenTo = source => source == activitySource, + Sample = (ref ActivityCreationOptions options) => + { + conversationIdAtCreation = options.Tags?.FirstOrDefault(tag => tag.Key == "gen_ai.conversation.id").Value; + return ActivitySamplingResult.AllData; + }, + }; + ActivitySource.AddActivityListener(listener); + var telemetryContext = new WorkflowTelemetryContext(new WorkflowTelemetryOptions(), activitySource); + Baggage previousBaggage = Baggage.Current; + + try + { + Baggage.SetBaggage("gen_ai.conversation.id", otelConversationId); + + // Act + using Activity? activity = telemetryContext.StartWorkflowRunActivity("customer_support_pipeline"); + + // Assert + Assert.NotNull(activity); + Assert.Equal(expectedConversationId, conversationIdAtCreation); + Assert.Equal(expectedConversationId, activity.GetTagItem("gen_ai.conversation.id")); + if (expectedConversationId is null) + { + Assert.DoesNotContain(activity.TagObjects, tag => tag.Key == "gen_ai.conversation.id"); + } + } + finally + { + Baggage.Current = previousBaggage; + } + } + + [Theory] + [InlineData("Lockstep", null)] + [InlineData("OffThread", null)] + [InlineData("Concurrent", null)] + [InlineData("Lockstep", "execution-only-session")] + [InlineData("OffThread", "execution-only-session")] + [InlineData("Concurrent", "execution-only-session")] + public async Task WorkflowRunActivity_WithoutConversationContext_DoesNotUseSessionIdAsync(string executionEnvironmentName, string? sessionId) + { + // Arrange + using var testActivity = new Activity("WorkflowConversationOmissionTest").Start(); + var workflow = CreateWorkflow("customer_support_pipeline"); + var executionEnvironment = GetExecutionEnvironment(executionEnvironmentName); + + // Act + Run run = await executionEnvironment.RunAsync(workflow, "Hello, World!", sessionId); + await run.DisposeAsync(); + + // Assert + var workflowRunActivity = Assert.Single(this._capturedActivities, a => + a.RootId == testActivity.RootId && + a.Events.Any(e => e.Name == EventNames.WorkflowStarted)); + Assert.DoesNotContain(workflowRunActivity.TagObjects, tag => tag.Key == "gen_ai.conversation.id"); + Assert.IsType(workflowRunActivity.GetTagItem("session.id")); + if (sessionId is not null) + { + Assert.Equal(sessionId, workflowRunActivity.GetTagItem("session.id")); + } + } + [Theory] [InlineData("")] [InlineData(" ")] diff --git a/skills/agent-framework-builder-dotnet/references/observability.md b/skills/agent-framework-builder-dotnet/references/observability.md index 03687fe03c2..5769ef4897c 100644 --- a/skills/agent-framework-builder-dotnet/references/observability.md +++ b/skills/agent-framework-builder-dotnet/references/observability.md @@ -20,6 +20,10 @@ runtime evidence; it does not determine whether output is correct. - Workflow invocation activities are named `invoke_workflow {gen_ai.workflow.name}`, or `invoke_workflow` when no meaningful workflow name exists. `gen_ai.operation.name` remains `invoke_workflow` and is available at creation for sampling. +- To correlate workflow invocations with an existing conversation, supply `gen_ai.conversation.id` through + OpenTelemetry baggage or the current `Activity`'s baggage. Non-blank OpenTelemetry baggage takes precedence. + Invocation activities record the ID at creation; without supplied context, the attribute is omitted. + Workflow execution `session.id` is separate and is not used as a fallback conversation ID. - Redaction does not replace sanitization in custom telemetry, exporters, middleware, logs (`UseLogging`), or exception messages. From 845ce612c8236d08349ff9c0a9f497446f943702 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 2 Nov 2026 12:54:25 -0500 Subject: [PATCH 6/7] .NET: use compiler-managed workflow activity disposal Address review comment 4445228927 with a using declaration for the lockstep invocation activity while preserving status cleanup. Remove misleading async-iterator disposal comments and verify cancellation, early enumeration disposal, consumer errors, and executor failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa4dae7a-fdbd-475c-8ec6-fa8333e77cc4 --- .../Execution/LockstepRunEventStream.cs | 7 +- .../WorkflowRunActivityStopTests.cs | 102 +++++++++++++++++- 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs index ee39b0fbbc6..2f35dfd7c03 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs @@ -62,8 +62,7 @@ public async IAsyncEnumerable TakeEventStreamAsync(bool blockOnPe // Re-establish session as parent so the run activity nests correctly. Activity.Current = this._sessionActivity; - // Not 'using' — must dispose explicitly in finally for deterministic export. - Activity? runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity(this._stepRunner.WorkflowName); + using Activity? runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity(this._stepRunner.WorkflowName); runActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.SessionId, this._stepRunner.SessionId); try @@ -167,10 +166,6 @@ public async IAsyncEnumerable TakeEventStreamAsync(bool blockOnPe finally { this.RunStatus = this._stepRunner.HasUnservicedRequests ? RunStatus.PendingRequests : RunStatus.Idle; - - // Explicitly dispose the Activity so Activity.Stop fires deterministically, - // regardless of how the async iterator enumerator is disposed. - runActivity?.Dispose(); } // If we are Idle or Ended, we should break out of the loop diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowRunActivityStopTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowRunActivityStopTests.cs index 6fc019c84cc..e1f1e135b8d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowRunActivityStopTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowRunActivityStopTests.cs @@ -63,8 +63,6 @@ private static Workflow CreateWorkflow() /// /// Verifies that the invoke_workflow Activity is stopped (and thus exportable) when /// using the Lockstep execution environment. - /// Bug: The Activity created by LockstepRunEventStream.TakeEventStreamAsync is never - /// disposed because yield break in async iterators does not trigger using disposal. /// [Fact] public async Task WorkflowRunActivity_IsStopped_LockstepAsync() @@ -104,6 +102,106 @@ public async Task WorkflowRunActivity_IsStopped_LockstepAsync() Assert.Single(stoppedWorkflowRuns ?? []); } + [Theory] + [InlineData("EarlyDisposal")] + [InlineData("Cancellation")] + [InlineData("ConsumerFailure")] + public async Task WorkflowRunActivity_IsStopped_Lockstep_InterruptedEnumerationAsync(string exitReason) + { + // Arrange + using var testActivity = new Activity("WorkflowRunStopTest_Lockstep_Interrupted").Start(); + var workflow = CreateWorkflow(); + var expectedException = new InvalidOperationException("Consumer failed."); + await using StreamingRun run = await InProcessExecution.Lockstep.RunStreamingAsync(workflow, "Hello, World!"); + int eventCount = 0; + + async Task ConsumeAsync() + { + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + eventCount++; + Assert.DoesNotContain(this._stoppedActivities, a => + a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)); + + switch (exitReason) + { + case "EarlyDisposal": + return; + case "Cancellation": + await run.CancelRunAsync(); + break; + case "ConsumerFailure": + throw expectedException; + default: + throw new ArgumentOutOfRangeException(nameof(exitReason)); + } + } + } + + // Act + if (string.Equals(exitReason, "ConsumerFailure", StringComparison.Ordinal)) + { + var actualException = await Assert.ThrowsAsync(ConsumeAsync); + Assert.Same(expectedException, actualException); + } + else + { + await ConsumeAsync(); + } + + // Assert + Assert.Equal(1, eventCount); + var startedRun = Assert.Single(this._startedActivities, a => + a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)); + var stoppedRun = Assert.Single(this._stoppedActivities, a => + a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)); + Assert.Same(startedRun, stoppedRun); + Assert.Equal(RunStatus.Idle, await run.GetStatusAsync()); + Assert.Same(testActivity, Activity.Current); + } + + [Fact] + public async Task WorkflowRunActivity_IsStopped_Lockstep_ExecutorFailureAsync() + { + // Arrange + using var testActivity = new Activity("WorkflowRunStopTest_Lockstep_ExecutorFailure").Start(); + var expectedException = new InvalidOperationException("Executor failed."); + Func failingFunc = _ => throw expectedException; + var executor = failingFunc.BindAsExecutor("FailingExecutor"); + var workflow = new WorkflowBuilder(executor) + .WithOutputFrom(executor) + .WithName("failing_workflow") + .WithOpenTelemetry() + .Build(); + await using StreamingRun run = await InProcessExecution.Lockstep.RunStreamingAsync(workflow, "Hello, World!"); + ExecutorFailedEvent? failureEvent = null; + + // Act + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + if (evt is ExecutorFailedEvent failure) + { + failureEvent = failure; + } + } + + // Assert + Assert.NotNull(failureEvent); + Assert.Same(expectedException, failureEvent.Data); + var startedRun = Assert.Single(this._startedActivities, a => + a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)); + var stoppedRun = Assert.Single(this._stoppedActivities, a => + a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)); + Assert.Same(startedRun, stoppedRun); + Assert.Equal(RunStatus.Idle, await run.GetStatusAsync()); + Assert.Same(testActivity, Activity.Current); + } + /// /// Verifies that the invoke_workflow Activity is stopped when using the OffThread (Default) /// execution environment (StreamingRunEventStream). From 95d38a641835e64194a258072a846b210fa3eea6 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 2 Nov 2026 13:07:42 -0500 Subject: [PATCH 7/7] chore: Update Activity.Current assignment for run activity Ensure Activity.Current is set to a valid session activity. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Execution/LockstepRunEventStream.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs index 2f35dfd7c03..6c7b5415139 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs @@ -60,7 +60,7 @@ public async IAsyncEnumerable TakeEventStreamAsync(bool blockOnPe using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(this._stopCancellation.Token, cancellationToken); // Re-establish session as parent so the run activity nests correctly. - Activity.Current = this._sessionActivity; + Activity.Current = this._sessionActivity ?? Activity.Current; using Activity? runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity(this._stepRunner.WorkflowName); runActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.SessionId, this._stepRunner.SessionId);