I have a custom ILogger
implementation along with an ILoggerProvider
and I've noticed that the categoryName
parameter in ILoggerProvider.CreateLogger
seems to be Type.FullName
:
Gets the fully qualified name of the type, including its namespace but not its assembly.
However, my production code is obfuscated, and while not all consumers within the codebase are obfuscated, most are and their names become something insignificant (e.g. ╠
), but alas, this is the nature of obfuscation.
I took some time to create a setup to test out @madreflection's curiosity surrounding the nameof
expression:
public class SampleAttribute : Attribute {
public string Name { get; set; }
public SampleAttribute(string name) =>
Name = name;
}
...
[Sample(nameof(InvoiceService))]
[Obfuscation(Exclude = false)]
public class TestClass {
public TestClass(ILogger<TestClass> logger) {
var attribute = GetType().GetCustomAttribute<SampleAttribute>();
logger.LogInformation($"inline: {nameof(TestClass)}");
logger.LogInformation($"attribute: {attribute.Name}");
}
}
The output shows that the nameof
expression is not obfuscated:
inline: TestClass
attribute: TestClass
Now, I'm curious as to where they're going with it! 🤔
With this in mind, is there a documented way to change the category name to a constant value?
A "simple" solution would be to inject ILoggerFactory
and call CreateLogger
. The category name is a parameter so it can be provided directly.
However, injecting ILogger<T>
is often the preferred pattern, and switching to injecting ILoggerFactory
could be a non-trivial undertaking. This solution helps avoid that.
There are 3 parts to this solution:
ILoggerFactory
implementation that replaces an obfuscated type name with the attribute value, if present.ILoggerFactory
implementation.Thanks to @Taco タコス's testing, we know that the obfuscator in question isn't trying to find the type name in string literals and obfuscate it there. That means that nameof
can provide meaningful values for the category name in the attribute.
First, we need an attribute. It's nothing special, but here it is.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public class LoggerCategoryAttribute : Attribute
{
public LoggerCategoryAttribute(string name)
{
Name = name;
}
public string Name { get; }
}
This attribute can be applied to each class or struct that's used as the T
in ILogger<T>
. It doesn't even have to be injected into that T
's constructor; it could be used anywhere.
Next, we'll create a custom ILoggerFactory
implementation that wraps a real one. The real logger factory could be the default LoggerFactory
or it could be an implementation provided by another logging framework.
The CreateLogger
method receives a categoryName
parameter which might be a fully qualified type name, but it could be anything. If categoryName
is a type name and Type.GetType
is able to find its Type
instance, CreateLogger
looks for the LoggerCategory
attribute to get the name and assigns it to categoryName
if it's not null/empty. Otherwise, it leaves categoryName
alone.
The other methods simply forward to the wrapped ILoggerFactory
implementation.
public sealed class RecategorizingLoggerFactory : ILoggerFactory
{
private readonly ILoggerFactory _originalLoggerFactory;
public RecategorizingLoggerFactory(ILoggerFactory originalLoggerFactory)
{
_originalLoggerFactory = originalLoggerFactory;
}
public void AddProvider(ILoggerProvider provider) => _originalLoggerFactory.AddProvider(provider);
public ILogger CreateLogger(string categoryName)
{
Type? type = Type.GetType(categoryName);
if (type is not null)
{
var attribute = type.GetCustomAttribute<LoggerCategoryAttribute>();
if (!string.IsNullOrEmpty(attribute?.Name))
categoryName = attribute.Name;
}
return _originalLoggerFactory.CreateLogger(categoryName);
}
public void Dispose() => _originalLoggerFactory.Dispose();
}
Note: This class is sealed
to avoid analysis messages about calling GC.SuppressFinalize
in Dispose
.
Finally, we need to register the custom logger factory for dependency injection.
If there's already a registration for ILoggerFactory
, we can't use Add
or TryAdd
because the former would throw an exception and the latter would be a no-op. Instead, we need to find the existing registration and replace it in the list. Since IServiceCollection
inherits IList<ServiceDescriptor>
, it's easy to replace the service descriptor directly.
If there's no registration for ILoggerFactory
, we add a new one, and create a new LoggerFactory
to wrap, because our custom logger factory still needs to wrap something that can actually create loggers.
public static IServiceCollection InjectRecategorizingLoggerFactory(this IServiceCollection services)
{
if (services is null)
throw new ArgumentNullException(nameof(services));
// List<T> has FindIndex(), but IList<T> does not, so it's inline as a local function here.
static int FindIndex(IList<ServiceDescriptor> list, Func<ServiceDescriptor, bool> predicate)
{
for (int i = 0; i < list.Count; ++i)
{
if (predicate(list[i]))
return i;
}
return -1;
}
int loggerFactoryIndex = FindIndex(services, sd => sd.ServiceType == typeof(ILoggerFactory));
// These two variables will be assigned eventually. Initializing them here prevents definite
// assignment analysis from detecting that all branches of execution below have assigned
// them, and with such large blocks, that can be a real concern, so they are intentionally
// not initialized.
Func<IServiceProvider, object> serviceFactory;
ServiceLifetime lifetime;
if (loggerFactoryIndex >= 0)
{
var oldServiceDescriptor = services[loggerFactoryIndex];
lifetime = oldServiceDescriptor.Lifetime;
// The service could be registered in one of three ways. The first two are easy to support,
// but the third can be fragile. See the notes below the code for details.
if (oldServiceDescriptor.ImplementationFactory is not null)
{
serviceFactory = oldServiceDescriptor.ImplementationFactory;
}
else if (oldServiceDescriptor.ImplementationInstance is ILoggerFactory oldLoggerFactory)
{
serviceFactory = sp => oldLoggerFactory;
}
else if (oldServiceDescriptor.ImplementationType is Type implementationType)
{
serviceFactory = sp =>
{
var loggerProviders = sp.GetServices<ILoggerProvider>();
var filterOption = sp.GetRequiredService<IOptionsMonitor<LoggerFilterOptions>>();
var options = sp.GetService<IOptions<LoggerFactoryOptions>>();
try
{
// BANG: ActivatorUtilities.CreateInstance is not annotated to accept null
// elements in the 'arguments' parameter, but the constructor we want
// to use takes a nullable IOptions<LoggerFactoryOptions>. The method
// signature is too restrictive, and passing null works where null is
// allowed by the constructor.
return ActivatorUtilities.CreateInstance(
sp,
implementationType,
new object[] { loggerProviders, filterOption, options! });
}
catch
{
return new LoggerFactory(loggerProviders, filterOption, options);
}
};
}
else
{
throw new InvalidOperationException("Invalid service descriptor encountered for ILoggerFactory.");
}
}
else
{
lifetime = ServiceLifetime.Singleton;
// No ILoggerFactory was registered. Default to wrapping LoggerFactory.
serviceFactory = sp => new LoggerFactory(
sp.GetServices<ILoggerProvider>(),
sp.GetRequiredService<IOptionsMonitor<LoggerFilterOptions>>(),
sp.GetService<IOptions<LoggerFactoryOptions>>());
}
var newServiceDescriptor = new ServiceDescriptor(
typeof(ILoggerFactory),
sp => new RecategorizingLoggerFactory((ILoggerFactory)serviceFactory(sp)),
lifetime);
if (loggerFactoryIndex >= 0)
{
services[loggerFactoryIndex] = newServiceDescriptor;
}
else
{
services.Add(newServiceDescriptor);
}
return services;
}
Some points of note about the above code:
Although the loggerFactoryIndex >= 0
test could be done once with things moved around a bit, I did it twice because I only wanted one place where newServiceDescriptor
is instantiated, since it's rather important and everything was intended to lead to that point.
If an ILoggerFactory
implementation other than the default LoggerFactory
was already registered, and only if it was registered with implementationType
rather than implementationInstance
or implementationFactory
(these are AddSingleton
parameter names), ActivatorUtilities.CreateInstance
could fail. This code contains a modest attempt not to hard-code LoggerFactory
. A lot more could be done to provide better support for wrapping other logger factories. I'm leaving that as an exercise to the reader.
I've used the "BANG:" comment to justify the use of the null-forgiving operator. I've found that in code reviews, this has led to improvements in various ways once other eyes are drawn to it, often to the point of not needing to use the operator at all. When it can't be rewritten away, it can help point out potential reasons for NullReferenceException
s when the justification has ceased to be valid.
There should be no reason to get an exception for an "invalid service descriptor" and ServiceDescriptor
ensures proper construction, unless reflection were used to null out all the private fields. It's primarily there for definite assignment analysis, as noted in one of the comments.
For completeness, and perhaps a bit of levity, here are some examples of how you might apply the attribute to a HomeController
class.
// Confirmed, nameof works.
[LoggerCategory(nameof(HomeController))]
// But you don't have to use nameof if you don't wnat to.
[LoggerCategory("HomeController")]
// Who needs "Controller" anyway? The *Name* of the controller is "Home", after all!
[LoggerCategory("Home")]
// You can include a hard-coded namespace so it looks like the original,
// unobfuscated type name.
[LoggerCategory($"MyApplication.Controllers.{nameof(HomeController)}")]
// A more robust version of the former. As long as the depth of namespace
// doesn't change, this will capture renaming of *any* of the parts.
[LoggerCategory($"{nameof(MyApplication)}.{nameof(MyApplication.Controllers)}.{nameof(HomeController)}")]