From 7b5631ce22cab140dcd9367e298f766a82ba8bc2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:20:53 +0000 Subject: [PATCH 1/8] Initial plan From 4f48371dd1d58cb57e21006bcd2c96f4a8072e83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:28:52 +0000 Subject: [PATCH 2/8] Investigate and understand test failure caused by dual-category logging Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- src/Worker/Core/DurableTaskWorkerOptions.cs | 62 ++++ src/Worker/Grpc/DualCategoryLogger.cs | 93 ++++++ src/Worker/Grpc/GrpcDurableTaskWorker.cs | 17 +- .../Worker/Grpc.Tests/LoggingCategoryTests.cs | 299 ++++++++++++++++++ .../RunBackgroundTaskLoggingTests.cs | 2 +- 5 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 src/Worker/Grpc/DualCategoryLogger.cs create mode 100644 test/Worker/Grpc.Tests/LoggingCategoryTests.cs diff --git a/src/Worker/Core/DurableTaskWorkerOptions.cs b/src/Worker/Core/DurableTaskWorkerOptions.cs index 703bbbd4..69c3565e 100644 --- a/src/Worker/Core/DurableTaskWorkerOptions.cs +++ b/src/Worker/Core/DurableTaskWorkerOptions.cs @@ -151,6 +151,20 @@ public DataConverter DataConverter [Obsolete("Experimental")] public IOrchestrationFilter? OrchestrationFilter { get; set; } + /// + /// Gets options for the Durable Task worker logging. + /// + /// + /// + /// Logging options control how logging categories are assigned to different components of the worker. + /// Starting from a future version, more specific logging categories will be used for better log filtering. + /// + /// To maintain backward compatibility, legacy logging categories are emitted by default alongside the new + /// categories. This can be disabled by setting to false. + /// + /// + public LoggingOptions Logging { get; } = new(); + /// /// Gets a value indicating whether was explicitly set or not. /// @@ -177,6 +191,7 @@ internal void ApplyTo(DurableTaskWorkerOptions other) other.EnableEntitySupport = this.EnableEntitySupport; other.Versioning = this.Versioning; other.OrchestrationFilter = this.OrchestrationFilter; + other.Logging.UseLegacyCategories = this.Logging.UseLegacyCategories; } } @@ -229,4 +244,51 @@ public class VersioningOptions /// public VersionFailureStrategy FailureStrategy { get; set; } = VersionFailureStrategy.Reject; } + + /// + /// Options for the Durable Task worker logging. + /// + /// + /// + /// These options control how logging categories are assigned to different components of the worker. + /// Starting from a future version, more specific logging categories will be used for better log filtering: + /// + /// Microsoft.DurableTask.Worker.Grpc for gRPC worker logs (previously Microsoft.DurableTask) + /// Microsoft.DurableTask.Worker.* for worker-specific logs + /// + /// + /// To maintain backward compatibility, legacy logging categories are emitted by default alongside the new + /// categories until a future major release. This ensures existing log filters continue to work. + /// + /// Migration Path: + /// + /// Update your log filters to use the new, more specific categories + /// Test your application to ensure logs are captured correctly + /// Once confident, set to false to disable legacy category emission + /// + /// + /// + public class LoggingOptions + { + /// + /// Gets or sets a value indicating whether to emit logs using legacy logging categories in addition to new categories. + /// + /// + /// + /// When true (default), logs are emitted to both the new specific categories (e.g., Microsoft.DurableTask.Worker.Grpc) + /// and the legacy broad categories (e.g., Microsoft.DurableTask). This ensures backward compatibility with existing + /// log filters and queries. + /// + /// When false, logs are only emitted to the new specific categories, which provides better log organization + /// and filtering capabilities. + /// + /// Default: true (legacy categories are enabled for backward compatibility) + /// + /// Breaking Change Warning: Setting this to false is a breaking change if you have existing log filters, + /// queries, or monitoring rules that depend on the legacy category names. Ensure you update those before disabling + /// legacy categories. + /// + /// + public bool UseLegacyCategories { get; set; } = true; + } } diff --git a/src/Worker/Grpc/DualCategoryLogger.cs b/src/Worker/Grpc/DualCategoryLogger.cs new file mode 100644 index 00000000..f514411e --- /dev/null +++ b/src/Worker/Grpc/DualCategoryLogger.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DurableTask.Worker.Grpc; + +/// +/// A logger wrapper that emits logs to both a primary (new) category and an optional legacy category. +/// +/// +/// This logger is used to maintain backward compatibility while transitioning to more specific logging categories. +/// When legacy categories are enabled, log messages are written to both the new specific category +/// (e.g., "Microsoft.DurableTask.Worker.Grpc") and the legacy broad category (e.g., "Microsoft.DurableTask"). +/// +sealed class DualCategoryLogger : ILogger +{ + readonly ILogger primaryLogger; + readonly ILogger? legacyLogger; + + /// + /// Initializes a new instance of the class. + /// + /// The primary logger with the new category. + /// The optional legacy logger with the old category. + public DualCategoryLogger(ILogger primaryLogger, ILogger? legacyLogger) + { + this.primaryLogger = Check.NotNull(primaryLogger); + this.legacyLogger = legacyLogger; + } + + /// + public IDisposable? BeginScope(TState state) + where TState : notnull + { + IDisposable? primaryScope = this.primaryLogger.BeginScope(state); + IDisposable? legacyScope = this.legacyLogger?.BeginScope(state); + + if (primaryScope is not null && legacyScope is not null) + { + return new CompositeDisposable(primaryScope, legacyScope); + } + + return primaryScope ?? legacyScope; + } + + /// + public bool IsEnabled(LogLevel logLevel) + { + // Return true if either logger is enabled at this level + return this.primaryLogger.IsEnabled(logLevel) || + (this.legacyLogger?.IsEnabled(logLevel) ?? false); + } + + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + // Log to primary logger + if (this.primaryLogger.IsEnabled(logLevel)) + { + this.primaryLogger.Log(logLevel, eventId, state, exception, formatter); + } + + // Log to legacy logger if enabled + if (this.legacyLogger?.IsEnabled(logLevel) ?? false) + { + this.legacyLogger.Log(logLevel, eventId, state, exception, formatter); + } + } + + sealed class CompositeDisposable : IDisposable + { + readonly IDisposable first; + readonly IDisposable second; + + public CompositeDisposable(IDisposable first, IDisposable second) + { + this.first = first; + this.second = second; + } + + public void Dispose() + { + this.first.Dispose(); + this.second.Dispose(); + } + } +} diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.cs index da61d884..93875961 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.cs @@ -45,7 +45,7 @@ public GrpcDurableTaskWorker( this.workerOptions = Check.NotNull(workerOptions).Get(name); this.services = Check.NotNull(services); this.loggerFactory = Check.NotNull(loggerFactory); - this.logger = loggerFactory.CreateLogger("Microsoft.DurableTask"); // TODO: use better category name. + this.logger = CreateLogger(loggerFactory, this.workerOptions); this.orchestrationFilter = orchestrationFilter; this.ExceptionPropertiesProvider = exceptionPropertiesProvider; } @@ -103,4 +103,19 @@ AsyncDisposable GetCallInvoker(out CallInvoker callInvoker, out string address) address = c.Target; return new AsyncDisposable(() => new(c.ShutdownAsync())); } + + static ILogger CreateLogger(ILoggerFactory loggerFactory, DurableTaskWorkerOptions options) + { + // Use the new, more specific category name for gRPC worker logs + ILogger primaryLogger = loggerFactory.CreateLogger("Microsoft.DurableTask.Worker.Grpc"); + + // If legacy categories are enabled, also emit logs to the old broad category + if (options.Logging.UseLegacyCategories) + { + ILogger legacyLogger = loggerFactory.CreateLogger("Microsoft.DurableTask"); + return new DualCategoryLogger(primaryLogger, legacyLogger); + } + + return primaryLogger; + } } diff --git a/test/Worker/Grpc.Tests/LoggingCategoryTests.cs b/test/Worker/Grpc.Tests/LoggingCategoryTests.cs new file mode 100644 index 00000000..a776f55d --- /dev/null +++ b/test/Worker/Grpc.Tests/LoggingCategoryTests.cs @@ -0,0 +1,299 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.DurableTask.Tests.Logging; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.Grpc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.DurableTask.Worker.Grpc.Tests; + +/// +/// Tests for logging category functionality, including dual-category emission for backward compatibility. +/// +public class LoggingCategoryTests +{ + const string NewGrpcCategory = "Microsoft.DurableTask.Worker.Grpc"; + + [Fact] + public void Worker_UsesLegacyCategories_ByDefault() + { + // Arrange & Act + var workerOptions = new DurableTaskWorkerOptions(); + + // Assert + workerOptions.Logging.UseLegacyCategories.Should().BeTrue("backward compatibility should be enabled by default"); + } + + [Fact] + public void Worker_CanDisableLegacyCategories() + { + // Arrange + var workerOptions = new DurableTaskWorkerOptions + { + Logging = { UseLegacyCategories = false } + }; + + // Act & Assert + workerOptions.Logging.UseLegacyCategories.Should().BeFalse("legacy categories can be explicitly disabled"); + } + + [Fact] + public void DualCategoryLogger_LogsToBothLoggers_WhenBothEnabled() + { + // Arrange + var primaryLogger = new Mock(); + var legacyLogger = new Mock(); + + primaryLogger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + legacyLogger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + + var dualLogger = new DualCategoryLogger(primaryLogger.Object, legacyLogger.Object); + + // Act + dualLogger.LogInformation("Test message"); + + // Assert - verify both loggers received the log call + primaryLogger.Verify( + l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Test message")), + null, + It.IsAny>()), + Times.Once, + "primary logger should receive the log"); + + legacyLogger.Verify( + l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Test message")), + null, + It.IsAny>()), + Times.Once, + "legacy logger should receive the log"); + } + + [Fact] + public void DualCategoryLogger_LogsToPrimaryOnly_WhenLegacyIsNull() + { + // Arrange + var logProvider = new TestLogProvider(new NullOutput()); + var loggerFactory = new SimpleLoggerFactory(logProvider); + + ILogger primaryLogger = loggerFactory.CreateLogger(NewGrpcCategory); + + var dualLogger = new DualCategoryLogger(primaryLogger, null); + + // Act + dualLogger.LogInformation("Test message"); + + // Assert + logProvider.TryGetLogs(NewGrpcCategory, out var newLogs).Should().BeTrue(); + newLogs.Should().ContainSingle(l => l.Message.Contains("Test message")); + } + + [Fact] + public void DualCategoryLogger_IsEnabled_ReturnsTrueIfEitherLoggerEnabled() + { + // Arrange + var primaryLogger = new Mock(); + var legacyLogger = new Mock(); + + primaryLogger.Setup(l => l.IsEnabled(LogLevel.Information)).Returns(true); + legacyLogger.Setup(l => l.IsEnabled(LogLevel.Information)).Returns(false); + + var dualLogger = new DualCategoryLogger(primaryLogger.Object, legacyLogger.Object); + + // Act + bool result = dualLogger.IsEnabled(LogLevel.Information); + + // Assert + result.Should().BeTrue("at least one logger is enabled"); + } + + [Fact] + public void DualCategoryLogger_IsEnabled_ReturnsFalseIfNeitherLoggerEnabled() + { + // Arrange + var primaryLogger = new Mock(); + var legacyLogger = new Mock(); + + primaryLogger.Setup(l => l.IsEnabled(LogLevel.Information)).Returns(false); + legacyLogger.Setup(l => l.IsEnabled(LogLevel.Information)).Returns(false); + + var dualLogger = new DualCategoryLogger(primaryLogger.Object, legacyLogger.Object); + + // Act + bool result = dualLogger.IsEnabled(LogLevel.Information); + + // Assert + result.Should().BeFalse("neither logger is enabled"); + } + + [Fact] + public void DualCategoryLogger_BeginScope_CreatesScopeInBothLoggers() + { + // Arrange + var primaryLogger = new Mock(); + var legacyLogger = new Mock(); + + var primaryDisposable = new Mock(); + var legacyDisposable = new Mock(); + + primaryLogger.Setup(l => l.BeginScope(It.IsAny())).Returns(primaryDisposable.Object); + legacyLogger.Setup(l => l.BeginScope(It.IsAny())).Returns(legacyDisposable.Object); + + var dualLogger = new DualCategoryLogger(primaryLogger.Object, legacyLogger.Object); + + // Act + using IDisposable? scope = dualLogger.BeginScope("test"); + + // Assert + primaryLogger.Verify(l => l.BeginScope("test"), Times.Once); + legacyLogger.Verify(l => l.BeginScope("test"), Times.Once); + + scope.Should().NotBeNull(); + } + + [Fact] + public void GrpcDurableTaskWorker_EmitsToBothCategories_WhenLegacyCategoriesEnabled() + { + // Arrange + var logProvider = new TestLogProvider(new NullOutput()); + var loggerFactory = new SimpleLoggerFactory(logProvider); + + var workerOptions = new DurableTaskWorkerOptions + { + Logging = { UseLegacyCategories = true } + }; + + var grpcOptions = new GrpcDurableTaskWorkerOptions(); + var factoryMock = new Mock(MockBehavior.Strict); + var services = new ServiceCollection().BuildServiceProvider(); + + // Act - Create worker which will create the logger internally + _ = new GrpcDurableTaskWorker( + name: "Test", + factory: factoryMock.Object, + grpcOptions: new OptionsMonitorStub(grpcOptions), + workerOptions: new OptionsMonitorStub(workerOptions), + services: services, + loggerFactory: loggerFactory, + orchestrationFilter: null, + exceptionPropertiesProvider: null); + + // Trigger a log by using the worker's logger (accessed via reflection or by starting the worker) + // Since we can't easily access the private logger, we verify that loggers were created + ILogger testLogger = loggerFactory.CreateLogger(NewGrpcCategory); + testLogger.LogInformation("Integration test log"); + + ILogger legacyLogger = loggerFactory.CreateLogger("Microsoft.DurableTask"); + legacyLogger.LogInformation("Integration test log"); + + // Assert - verify both categories receive logs + logProvider.TryGetLogs(NewGrpcCategory, out var newLogs).Should().BeTrue("new category logger should receive logs"); + newLogs.Should().NotBeEmpty("logs should be written to new category"); + + logProvider.TryGetLogs("Microsoft.DurableTask", out var legacyLogs).Should().BeTrue("legacy category logger should receive logs"); + legacyLogs.Should().NotBeEmpty("logs should be written to legacy category"); + } + + [Fact] + public void GrpcDurableTaskWorker_EmitsToNewCategoryOnly_WhenLegacyCategoriesDisabled() + { + // Arrange + var logProvider = new TestLogProvider(new NullOutput()); + var loggerFactory = new SimpleLoggerFactory(logProvider); + + var workerOptions = new DurableTaskWorkerOptions + { + Logging = { UseLegacyCategories = false } + }; + + var grpcOptions = new GrpcDurableTaskWorkerOptions(); + var factoryMock = new Mock(MockBehavior.Strict); + var services = new ServiceCollection().BuildServiceProvider(); + + // Act - Create worker which will create the logger internally + _ = new GrpcDurableTaskWorker( + name: "Test", + factory: factoryMock.Object, + grpcOptions: new OptionsMonitorStub(grpcOptions), + workerOptions: new OptionsMonitorStub(workerOptions), + services: services, + loggerFactory: loggerFactory, + orchestrationFilter: null, + exceptionPropertiesProvider: null); + + // Trigger a log only to the new category + ILogger testLogger = loggerFactory.CreateLogger(NewGrpcCategory); + testLogger.LogInformation("Integration test log"); + + // Assert - verify logs appear only in new category + logProvider.TryGetLogs(NewGrpcCategory, out var newLogs).Should().BeTrue("new category logger should receive logs"); + newLogs.Should().NotBeEmpty("logs should be written to new category"); + newLogs.Should().AllSatisfy(log => log.Category.Should().Be(NewGrpcCategory, "all logs should be in new category")); + + // Verify we didn't create a legacy logger (by checking directly) + // The TestLogProvider uses StartsWith, so we check that no logs exist with exactly the legacy category + var allLogs = newLogs.ToList(); + allLogs.Should().NotContain(log => log.Category == "Microsoft.DurableTask", + "no logs should have exactly the legacy category when disabled"); + } + +} + +sealed class OptionsMonitorStub : IOptionsMonitor +{ + readonly T value; + + public OptionsMonitorStub(T value) + { + this.value = value; + } + + public T CurrentValue => this.value; + + public T Get(string? name) => this.value; + + public IDisposable OnChange(Action listener) => NullDisposable.Instance; + + sealed class NullDisposable : IDisposable + { + public static readonly NullDisposable Instance = new(); + public void Dispose() { } + } +} + +sealed class NullOutput : ITestOutputHelper +{ + public void WriteLine(string message) { } + public void WriteLine(string format, params object[] args) { } +} + +sealed class SimpleLoggerFactory : ILoggerFactory +{ + readonly ILoggerProvider provider; + + public SimpleLoggerFactory(ILoggerProvider provider) + { + this.provider = provider; + } + + public void AddProvider(ILoggerProvider loggerProvider) + { + // No-op; single provider + } + + public ILogger CreateLogger(string categoryName) => this.provider.CreateLogger(categoryName); + + public void Dispose() { } +} diff --git a/test/Worker/Grpc.Tests/RunBackgroundTaskLoggingTests.cs b/test/Worker/Grpc.Tests/RunBackgroundTaskLoggingTests.cs index 2a907865..86f9afec 100644 --- a/test/Worker/Grpc.Tests/RunBackgroundTaskLoggingTests.cs +++ b/test/Worker/Grpc.Tests/RunBackgroundTaskLoggingTests.cs @@ -20,7 +20,7 @@ namespace Microsoft.DurableTask.Worker.Grpc.Tests; public class RunBackgroundTaskLoggingTests { - const string Category = "Microsoft.DurableTask"; + const string Category = "Microsoft.DurableTask.Worker.Grpc"; [Fact] public async Task Logs_Abandoning_And_Abandoned_For_Orchestrator() From c2f2f66ec95b0ce7d24142e9d059f386ad7d0790 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:37:52 +0000 Subject: [PATCH 3/8] Fix test failure: update GetLogs() to use specific category to avoid duplicates Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- test/Grpc.IntegrationTests/IntegrationTestBase.cs | 7 +++++-- test/Worker/Grpc.Tests/LoggingCategoryTests.cs | 7 ++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/test/Grpc.IntegrationTests/IntegrationTestBase.cs b/test/Grpc.IntegrationTests/IntegrationTestBase.cs index 13d92a7a..b1f98a78 100644 --- a/test/Grpc.IntegrationTests/IntegrationTestBase.cs +++ b/test/Grpc.IntegrationTests/IntegrationTestBase.cs @@ -95,8 +95,11 @@ protected IHostBuilder CreateHostBuilder( protected IReadOnlyCollection GetLogs() { - // NOTE: Renaming the log category is a breaking change! - const string ExpectedCategoryName = "Microsoft.DurableTask"; + // Use the specific Worker.Grpc category to get gRPC worker logs. + // Note: TryGetLogs uses StartsWith matching, so "Microsoft.DurableTask" would match both + // the legacy category AND "Microsoft.DurableTask.Worker.Grpc", causing duplicate entries + // when dual-category logging is enabled. Using the specific category avoids this issue. + const string ExpectedCategoryName = "Microsoft.DurableTask.Worker.Grpc"; bool foundCategory = this.logProvider.TryGetLogs(ExpectedCategoryName, out IReadOnlyCollection logs); Assert.True(foundCategory); return logs; diff --git a/test/Worker/Grpc.Tests/LoggingCategoryTests.cs b/test/Worker/Grpc.Tests/LoggingCategoryTests.cs index a776f55d..397fc3ff 100644 --- a/test/Worker/Grpc.Tests/LoggingCategoryTests.cs +++ b/test/Worker/Grpc.Tests/LoggingCategoryTests.cs @@ -20,6 +20,7 @@ namespace Microsoft.DurableTask.Worker.Grpc.Tests; public class LoggingCategoryTests { const string NewGrpcCategory = "Microsoft.DurableTask.Worker.Grpc"; + const string LegacyCategory = "Microsoft.DurableTask"; [Fact] public void Worker_UsesLegacyCategories_ByDefault() @@ -195,14 +196,14 @@ public void GrpcDurableTaskWorker_EmitsToBothCategories_WhenLegacyCategoriesEnab ILogger testLogger = loggerFactory.CreateLogger(NewGrpcCategory); testLogger.LogInformation("Integration test log"); - ILogger legacyLogger = loggerFactory.CreateLogger("Microsoft.DurableTask"); + ILogger legacyLogger = loggerFactory.CreateLogger(LegacyCategory); legacyLogger.LogInformation("Integration test log"); // Assert - verify both categories receive logs logProvider.TryGetLogs(NewGrpcCategory, out var newLogs).Should().BeTrue("new category logger should receive logs"); newLogs.Should().NotBeEmpty("logs should be written to new category"); - logProvider.TryGetLogs("Microsoft.DurableTask", out var legacyLogs).Should().BeTrue("legacy category logger should receive logs"); + logProvider.TryGetLogs(LegacyCategory, out var legacyLogs).Should().BeTrue("legacy category logger should receive logs"); legacyLogs.Should().NotBeEmpty("logs should be written to legacy category"); } @@ -245,7 +246,7 @@ public void GrpcDurableTaskWorker_EmitsToNewCategoryOnly_WhenLegacyCategoriesDis // Verify we didn't create a legacy logger (by checking directly) // The TestLogProvider uses StartsWith, so we check that no logs exist with exactly the legacy category var allLogs = newLogs.ToList(); - allLogs.Should().NotContain(log => log.Category == "Microsoft.DurableTask", + allLogs.Should().NotContain(log => log.Category == LegacyCategory, "no logs should have exactly the legacy category when disabled"); } From 2429a1f989205f99e6e7133399f3cc54cd40390d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:12:24 +0000 Subject: [PATCH 4/8] Simplify comment in GetLogs method Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- test/Grpc.IntegrationTests/IntegrationTestBase.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Grpc.IntegrationTests/IntegrationTestBase.cs b/test/Grpc.IntegrationTests/IntegrationTestBase.cs index b1f98a78..ded82776 100644 --- a/test/Grpc.IntegrationTests/IntegrationTestBase.cs +++ b/test/Grpc.IntegrationTests/IntegrationTestBase.cs @@ -96,9 +96,9 @@ protected IHostBuilder CreateHostBuilder( protected IReadOnlyCollection GetLogs() { // Use the specific Worker.Grpc category to get gRPC worker logs. - // Note: TryGetLogs uses StartsWith matching, so "Microsoft.DurableTask" would match both + // TryGetLogs uses StartsWith matching, so "Microsoft.DurableTask" would match both // the legacy category AND "Microsoft.DurableTask.Worker.Grpc", causing duplicate entries - // when dual-category logging is enabled. Using the specific category avoids this issue. + // when dual-category logging is enabled. const string ExpectedCategoryName = "Microsoft.DurableTask.Worker.Grpc"; bool foundCategory = this.logProvider.TryGetLogs(ExpectedCategoryName, out IReadOnlyCollection logs); Assert.True(foundCategory); From b2b04ee06c9480f4b7a01ecd366ad5a4e559aa35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:20:42 +0000 Subject: [PATCH 5/8] Improve exception handling in CompositeDisposable.Dispose Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- src/Worker/Grpc/DualCategoryLogger.cs | 31 +++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/Worker/Grpc/DualCategoryLogger.cs b/src/Worker/Grpc/DualCategoryLogger.cs index f514411e..14366ebd 100644 --- a/src/Worker/Grpc/DualCategoryLogger.cs +++ b/src/Worker/Grpc/DualCategoryLogger.cs @@ -86,8 +86,35 @@ public CompositeDisposable(IDisposable first, IDisposable second) public void Dispose() { - this.first.Dispose(); - this.second.Dispose(); + Exception? firstException = null; + + try + { + this.first.Dispose(); + } + catch (Exception ex) + { + firstException = ex; + } + + try + { + this.second.Dispose(); + } + catch (Exception ex) + { + if (firstException is null) + { + throw; + } + + throw new AggregateException(firstException, ex); + } + + if (firstException is not null) + { + throw firstException; + } } } } From dadaee9b0ac349402a9768a71e36345d7c3250fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:28:39 +0000 Subject: [PATCH 6/8] Improve integration tests to verify actual worker logger type via reflection Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- .../Worker/Grpc.Tests/LoggingCategoryTests.cs | 46 +++++++------------ 1 file changed, 16 insertions(+), 30 deletions(-) diff --git a/test/Worker/Grpc.Tests/LoggingCategoryTests.cs b/test/Worker/Grpc.Tests/LoggingCategoryTests.cs index 397fc3ff..0e4d37e0 100644 --- a/test/Worker/Grpc.Tests/LoggingCategoryTests.cs +++ b/test/Worker/Grpc.Tests/LoggingCategoryTests.cs @@ -165,7 +165,7 @@ public void DualCategoryLogger_BeginScope_CreatesScopeInBothLoggers() } [Fact] - public void GrpcDurableTaskWorker_EmitsToBothCategories_WhenLegacyCategoriesEnabled() + public void GrpcDurableTaskWorker_UsesDualCategoryLogger_WhenLegacyCategoriesEnabled() { // Arrange var logProvider = new TestLogProvider(new NullOutput()); @@ -181,7 +181,7 @@ public void GrpcDurableTaskWorker_EmitsToBothCategories_WhenLegacyCategoriesEnab var services = new ServiceCollection().BuildServiceProvider(); // Act - Create worker which will create the logger internally - _ = new GrpcDurableTaskWorker( + var worker = new GrpcDurableTaskWorker( name: "Test", factory: factoryMock.Object, grpcOptions: new OptionsMonitorStub(grpcOptions), @@ -191,24 +191,17 @@ public void GrpcDurableTaskWorker_EmitsToBothCategories_WhenLegacyCategoriesEnab orchestrationFilter: null, exceptionPropertiesProvider: null); - // Trigger a log by using the worker's logger (accessed via reflection or by starting the worker) - // Since we can't easily access the private logger, we verify that loggers were created - ILogger testLogger = loggerFactory.CreateLogger(NewGrpcCategory); - testLogger.LogInformation("Integration test log"); + // Use reflection to access the private logger field + var loggerField = typeof(GrpcDurableTaskWorker).GetField("logger", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var logger = loggerField?.GetValue(worker); - ILogger legacyLogger = loggerFactory.CreateLogger(LegacyCategory); - legacyLogger.LogInformation("Integration test log"); - - // Assert - verify both categories receive logs - logProvider.TryGetLogs(NewGrpcCategory, out var newLogs).Should().BeTrue("new category logger should receive logs"); - newLogs.Should().NotBeEmpty("logs should be written to new category"); - - logProvider.TryGetLogs(LegacyCategory, out var legacyLogs).Should().BeTrue("legacy category logger should receive logs"); - legacyLogs.Should().NotBeEmpty("logs should be written to legacy category"); + // Assert - verify the worker uses DualCategoryLogger when legacy categories are enabled + logger.Should().NotBeNull("worker should have a logger"); + logger.Should().BeOfType("worker should use DualCategoryLogger when UseLegacyCategories is true"); } [Fact] - public void GrpcDurableTaskWorker_EmitsToNewCategoryOnly_WhenLegacyCategoriesDisabled() + public void GrpcDurableTaskWorker_UsesRegularLogger_WhenLegacyCategoriesDisabled() { // Arrange var logProvider = new TestLogProvider(new NullOutput()); @@ -224,7 +217,7 @@ public void GrpcDurableTaskWorker_EmitsToNewCategoryOnly_WhenLegacyCategoriesDis var services = new ServiceCollection().BuildServiceProvider(); // Act - Create worker which will create the logger internally - _ = new GrpcDurableTaskWorker( + var worker = new GrpcDurableTaskWorker( name: "Test", factory: factoryMock.Object, grpcOptions: new OptionsMonitorStub(grpcOptions), @@ -234,20 +227,13 @@ public void GrpcDurableTaskWorker_EmitsToNewCategoryOnly_WhenLegacyCategoriesDis orchestrationFilter: null, exceptionPropertiesProvider: null); - // Trigger a log only to the new category - ILogger testLogger = loggerFactory.CreateLogger(NewGrpcCategory); - testLogger.LogInformation("Integration test log"); - - // Assert - verify logs appear only in new category - logProvider.TryGetLogs(NewGrpcCategory, out var newLogs).Should().BeTrue("new category logger should receive logs"); - newLogs.Should().NotBeEmpty("logs should be written to new category"); - newLogs.Should().AllSatisfy(log => log.Category.Should().Be(NewGrpcCategory, "all logs should be in new category")); + // Use reflection to access the private logger field + var loggerField = typeof(GrpcDurableTaskWorker).GetField("logger", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var logger = loggerField?.GetValue(worker); - // Verify we didn't create a legacy logger (by checking directly) - // The TestLogProvider uses StartsWith, so we check that no logs exist with exactly the legacy category - var allLogs = newLogs.ToList(); - allLogs.Should().NotContain(log => log.Category == LegacyCategory, - "no logs should have exactly the legacy category when disabled"); + // Assert - verify the worker uses a regular logger (not DualCategoryLogger) when legacy categories are disabled + logger.Should().NotBeNull("worker should have a logger"); + logger.Should().NotBeOfType("worker should not use DualCategoryLogger when UseLegacyCategories is false"); } } From 498c9049de537726483ee662e179ac1f223fe18b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:31:23 +0000 Subject: [PATCH 7/8] Extract reflection helper methods to reduce code duplication in tests Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- .../Worker/Grpc.Tests/LoggingCategoryTests.cs | 57 +++++++------------ 1 file changed, 20 insertions(+), 37 deletions(-) diff --git a/test/Worker/Grpc.Tests/LoggingCategoryTests.cs b/test/Worker/Grpc.Tests/LoggingCategoryTests.cs index 0e4d37e0..88fb3954 100644 --- a/test/Worker/Grpc.Tests/LoggingCategoryTests.cs +++ b/test/Worker/Grpc.Tests/LoggingCategoryTests.cs @@ -167,33 +167,9 @@ public void DualCategoryLogger_BeginScope_CreatesScopeInBothLoggers() [Fact] public void GrpcDurableTaskWorker_UsesDualCategoryLogger_WhenLegacyCategoriesEnabled() { - // Arrange - var logProvider = new TestLogProvider(new NullOutput()); - var loggerFactory = new SimpleLoggerFactory(logProvider); - - var workerOptions = new DurableTaskWorkerOptions - { - Logging = { UseLegacyCategories = true } - }; - - var grpcOptions = new GrpcDurableTaskWorkerOptions(); - var factoryMock = new Mock(MockBehavior.Strict); - var services = new ServiceCollection().BuildServiceProvider(); - - // Act - Create worker which will create the logger internally - var worker = new GrpcDurableTaskWorker( - name: "Test", - factory: factoryMock.Object, - grpcOptions: new OptionsMonitorStub(grpcOptions), - workerOptions: new OptionsMonitorStub(workerOptions), - services: services, - loggerFactory: loggerFactory, - orchestrationFilter: null, - exceptionPropertiesProvider: null); - - // Use reflection to access the private logger field - var loggerField = typeof(GrpcDurableTaskWorker).GetField("logger", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var logger = loggerField?.GetValue(worker); + // Arrange & Act + var worker = CreateWorkerWithLoggingOptions(useLegacyCategories: true); + var logger = GetWorkerLogger(worker); // Assert - verify the worker uses DualCategoryLogger when legacy categories are enabled logger.Should().NotBeNull("worker should have a logger"); @@ -203,21 +179,30 @@ public void GrpcDurableTaskWorker_UsesDualCategoryLogger_WhenLegacyCategoriesEna [Fact] public void GrpcDurableTaskWorker_UsesRegularLogger_WhenLegacyCategoriesDisabled() { - // Arrange + // Arrange & Act + var worker = CreateWorkerWithLoggingOptions(useLegacyCategories: false); + var logger = GetWorkerLogger(worker); + + // Assert - verify the worker uses a regular logger (not DualCategoryLogger) when legacy categories are disabled + logger.Should().NotBeNull("worker should have a logger"); + logger.Should().NotBeOfType("worker should not use DualCategoryLogger when UseLegacyCategories is false"); + } + + static GrpcDurableTaskWorker CreateWorkerWithLoggingOptions(bool useLegacyCategories) + { var logProvider = new TestLogProvider(new NullOutput()); var loggerFactory = new SimpleLoggerFactory(logProvider); var workerOptions = new DurableTaskWorkerOptions { - Logging = { UseLegacyCategories = false } + Logging = { UseLegacyCategories = useLegacyCategories } }; var grpcOptions = new GrpcDurableTaskWorkerOptions(); var factoryMock = new Mock(MockBehavior.Strict); var services = new ServiceCollection().BuildServiceProvider(); - // Act - Create worker which will create the logger internally - var worker = new GrpcDurableTaskWorker( + return new GrpcDurableTaskWorker( name: "Test", factory: factoryMock.Object, grpcOptions: new OptionsMonitorStub(grpcOptions), @@ -226,14 +211,12 @@ public void GrpcDurableTaskWorker_UsesRegularLogger_WhenLegacyCategoriesDisabled loggerFactory: loggerFactory, orchestrationFilter: null, exceptionPropertiesProvider: null); + } - // Use reflection to access the private logger field + static object? GetWorkerLogger(GrpcDurableTaskWorker worker) + { var loggerField = typeof(GrpcDurableTaskWorker).GetField("logger", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var logger = loggerField?.GetValue(worker); - - // Assert - verify the worker uses a regular logger (not DualCategoryLogger) when legacy categories are disabled - logger.Should().NotBeNull("worker should have a logger"); - logger.Should().NotBeOfType("worker should not use DualCategoryLogger when UseLegacyCategories is false"); + return loggerField?.GetValue(worker); } } From 9cc7afd1fce34160676cb3202bca596d09cb4505 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 21:44:35 +0000 Subject: [PATCH 8/8] Consolidate LoggingOptions docs and remove useless test Co-authored-by: cgillum <2704139+cgillum@users.noreply.github.com> --- src/Worker/Core/DurableTaskWorkerOptions.cs | 45 +++++++++---------- .../Worker/Grpc.Tests/LoggingCategoryTests.cs | 13 ------ 2 files changed, 20 insertions(+), 38 deletions(-) diff --git a/src/Worker/Core/DurableTaskWorkerOptions.cs b/src/Worker/Core/DurableTaskWorkerOptions.cs index 69c3565e..3aa4eea2 100644 --- a/src/Worker/Core/DurableTaskWorkerOptions.cs +++ b/src/Worker/Core/DurableTaskWorkerOptions.cs @@ -246,28 +246,8 @@ public class VersioningOptions } /// - /// Options for the Durable Task worker logging. + /// Options for configuring Durable Task worker logging behavior. /// - /// - /// - /// These options control how logging categories are assigned to different components of the worker. - /// Starting from a future version, more specific logging categories will be used for better log filtering: - /// - /// Microsoft.DurableTask.Worker.Grpc for gRPC worker logs (previously Microsoft.DurableTask) - /// Microsoft.DurableTask.Worker.* for worker-specific logs - /// - /// - /// To maintain backward compatibility, legacy logging categories are emitted by default alongside the new - /// categories until a future major release. This ensures existing log filters continue to work. - /// - /// Migration Path: - /// - /// Update your log filters to use the new, more specific categories - /// Test your application to ensure logs are captured correctly - /// Once confident, set to false to disable legacy category emission - /// - /// - /// public class LoggingOptions { /// @@ -275,15 +255,30 @@ public class LoggingOptions /// /// /// + /// Starting from a future version, more specific logging categories will be used for better log filtering: + /// + /// Microsoft.DurableTask.Worker.Grpc for gRPC worker logs (previously Microsoft.DurableTask) + /// Microsoft.DurableTask.Worker.* for worker-specific logs + /// + /// + /// /// When true (default), logs are emitted to both the new specific categories (e.g., Microsoft.DurableTask.Worker.Grpc) /// and the legacy broad categories (e.g., Microsoft.DurableTask). This ensures backward compatibility with existing /// log filters and queries. - /// + /// + /// /// When false, logs are only emitted to the new specific categories, which provides better log organization /// and filtering capabilities. - /// - /// Default: true (legacy categories are enabled for backward compatibility) - /// + /// + /// + /// Migration Path: + /// + /// Update your log filters to use the new, more specific categories + /// Test your application to ensure logs are captured correctly + /// Once confident, set this property to false to disable legacy category emission + /// + /// + /// /// Breaking Change Warning: Setting this to false is a breaking change if you have existing log filters, /// queries, or monitoring rules that depend on the legacy category names. Ensure you update those before disabling /// legacy categories. diff --git a/test/Worker/Grpc.Tests/LoggingCategoryTests.cs b/test/Worker/Grpc.Tests/LoggingCategoryTests.cs index 88fb3954..0dd5b347 100644 --- a/test/Worker/Grpc.Tests/LoggingCategoryTests.cs +++ b/test/Worker/Grpc.Tests/LoggingCategoryTests.cs @@ -32,19 +32,6 @@ public void Worker_UsesLegacyCategories_ByDefault() workerOptions.Logging.UseLegacyCategories.Should().BeTrue("backward compatibility should be enabled by default"); } - [Fact] - public void Worker_CanDisableLegacyCategories() - { - // Arrange - var workerOptions = new DurableTaskWorkerOptions - { - Logging = { UseLegacyCategories = false } - }; - - // Act & Assert - workerOptions.Logging.UseLegacyCategories.Should().BeFalse("legacy categories can be explicitly disabled"); - } - [Fact] public void DualCategoryLogger_LogsToBothLoggers_WhenBothEnabled() {