Skip to content
62 changes: 62 additions & 0 deletions src/Worker/Core/DurableTaskWorkerOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,20 @@ public DataConverter DataConverter
[Obsolete("Experimental")]
public IOrchestrationFilter? OrchestrationFilter { get; set; }

/// <summary>
/// Gets options for the Durable Task worker logging.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para><para>
/// To maintain backward compatibility, legacy logging categories are emitted by default alongside the new
/// categories. This can be disabled by setting <see cref="LoggingOptions.UseLegacyCategories" /> to false.
/// </para>
/// </remarks>
public LoggingOptions Logging { get; } = new();

/// <summary>
/// Gets a value indicating whether <see cref="DataConverter" /> was explicitly set or not.
/// </summary>
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -229,4 +244,51 @@ public class VersioningOptions
/// </remarks>
public VersionFailureStrategy FailureStrategy { get; set; } = VersionFailureStrategy.Reject;
}

/// <summary>
/// Options for the Durable Task worker logging.
/// </summary>
/// <remarks>
/// <para>
/// 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:
/// <list type="bullet">
/// <item><description><c>Microsoft.DurableTask.Worker.Grpc</c> for gRPC worker logs (previously <c>Microsoft.DurableTask</c>)</description></item>
/// <item><description><c>Microsoft.DurableTask.Worker.*</c> for worker-specific logs</description></item>
/// </list>
/// </para><para>
/// 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.
/// </para><para>
/// <b>Migration Path:</b>
/// <list type="number">
/// <item><description>Update your log filters to use the new, more specific categories</description></item>
/// <item><description>Test your application to ensure logs are captured correctly</description></item>
/// <item><description>Once confident, set <see cref="UseLegacyCategories" /> to <c>false</c> to disable legacy category emission</description></item>
/// </list>
/// </para>
/// </remarks>
public class LoggingOptions
{
/// <summary>
/// Gets or sets a value indicating whether to emit logs using legacy logging categories in addition to new categories.
/// </summary>
/// <remarks>
/// <para>
/// When <c>true</c> (default), logs are emitted to both the new specific categories (e.g., <c>Microsoft.DurableTask.Worker.Grpc</c>)
/// and the legacy broad categories (e.g., <c>Microsoft.DurableTask</c>). This ensures backward compatibility with existing
/// log filters and queries.
/// </para><para>
/// When <c>false</c>, logs are only emitted to the new specific categories, which provides better log organization
/// and filtering capabilities.
/// </para><para>
/// <b>Default:</b> <c>true</c> (legacy categories are enabled for backward compatibility)
/// </para><para>
/// <b>Breaking Change Warning:</b> Setting this to <c>false</c> 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.
/// </para>
/// </remarks>
public bool UseLegacyCategories { get; set; } = true;
}
}
120 changes: 120 additions & 0 deletions src/Worker/Grpc/DualCategoryLogger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Extensions.Logging;

namespace Microsoft.DurableTask.Worker.Grpc;

/// <summary>
/// A logger wrapper that emits logs to both a primary (new) category and an optional legacy category.
/// </summary>
/// <remarks>
/// 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").
/// </remarks>
sealed class DualCategoryLogger : ILogger
{
readonly ILogger primaryLogger;
readonly ILogger? legacyLogger;

/// <summary>
/// Initializes a new instance of the <see cref="DualCategoryLogger"/> class.
/// </summary>
/// <param name="primaryLogger">The primary logger with the new category.</param>
/// <param name="legacyLogger">The optional legacy logger with the old category.</param>
public DualCategoryLogger(ILogger primaryLogger, ILogger? legacyLogger)
{
this.primaryLogger = Check.NotNull(primaryLogger);
this.legacyLogger = legacyLogger;
}

/// <inheritdoc/>
public IDisposable? BeginScope<TState>(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;
}

/// <inheritdoc/>
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);
}

/// <inheritdoc/>
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> 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()
{
Exception? firstException = null;

try
{
this.first.Dispose();
}
catch (Exception ex)
{
firstException = ex;
}
Comment on lines +95 to +98

try
{
this.second.Dispose();
}
catch (Exception ex)
{
if (firstException is null)
{
throw;
}

throw new AggregateException(firstException, ex);
}

if (firstException is not null)
{
throw firstException;
}
}
}
}
17 changes: 16 additions & 1 deletion src/Worker/Grpc/GrpcDurableTaskWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
}
7 changes: 5 additions & 2 deletions test/Grpc.IntegrationTests/IntegrationTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,11 @@ protected IHostBuilder CreateHostBuilder(

protected IReadOnlyCollection<LogEntry> 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.
// 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.
const string ExpectedCategoryName = "Microsoft.DurableTask.Worker.Grpc";
bool foundCategory = this.logProvider.TryGetLogs(ExpectedCategoryName, out IReadOnlyCollection<LogEntry> logs);
Assert.True(foundCategory);
return logs;
Expand Down
Loading
Loading