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/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/Tags.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs index 47ce7017944..151d15137e6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs @@ -4,8 +4,10 @@ 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 = "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..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; @@ -109,15 +110,34 @@ 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 = new() { { Tags.OperationName, ActivityNames.WorkflowInvoke } }; + string activityName = ActivityNames.WorkflowInvoke; + if (!string.IsNullOrWhiteSpace(workflowName)) + { + tags.Add(Tags.WorkflowName, workflowName); + 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/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryOptions.cs index b32f0c0f669..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 workflow_invoke 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/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.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..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; @@ -56,7 +57,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 +69,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(); } @@ -158,6 +163,195 @@ public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_LockstepAsyn await this.TestWorkflowEndToEndActivitiesAsync("Lockstep"); } + [Theory] + [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(); + testActivity.AddBaggage("gen_ai.conversation.id", "application-thread"); + var workflow = CreateWorkflow(workflowName); + 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 capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); + var workflowRunActivity = Assert.Single(capturedActivities, a => + a.Events.Any(e => e.Name == EventNames.WorkflowStarted)); + 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.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 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")); + Assert.All(capturedActivities, a => Assert.DoesNotContain(a.TagObjects, tag => tag.Key == "workflow.name")); + } + + [Theory] + [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; + }, + }; + ActivitySource.AddActivityListener(listener); + var telemetryContext = new WorkflowTelemetryContext(new WorkflowTelemetryOptions(), activitySource); + + // Act + using Activity? activity = telemetryContext.StartWorkflowRunActivity(workflowName); + + // Assert + Assert.NotNull(activity); + Assert.Equal(expectedSpanName, spanNameAtCreation); + 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(" ")] + 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] 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..6fc019c84cc 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 @@ -57,11 +57,11 @@ 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(); } /// - /// 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] diff --git a/skills/agent-framework-builder-dotnet/references/observability.md b/skills/agent-framework-builder-dotnet/references/observability.md index 8ad331aebcb..5769ef4897c 100644 --- a/skills/agent-framework-builder-dotnet/references/observability.md +++ b/skills/agent-framework-builder-dotnet/references/observability.md @@ -15,6 +15,15 @@ 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. +- 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.