Repository navigation
Enhance user-facing API for strongly-typed ILogger messages #36022
Description
Is your feature request related to a problem? Please describe.
I'm trying to declare a strongly-typed code path for ILogger messages, but it is difficult to use the Action-returning static method LoggerMessage.Define<T1,T2,...,Tn> in a way that's approachable and maintainable by an entire team.
Describe the solution you'd like
It would be great if there was a strongly-typed struct which could wrap the underlying LoggerMessage.Define call and Action<ILoggerFactory, ...., Exception> field - even completely hide the actual Action signature. Ideally it would also provide more appropriate intellisense on the struct method which calls the action. If would be even cleaner if it could be initialized without repeating the generic argument types, and in most end-user applications if the EventId number was optional it could be derived from the EventId name instead.
internal static class MyAppLoggerExtensions
{
private readonly static LogMessage<string, int> _logSayHello = (
LogLevel.Debug,
nameof(SayHello),
"The program named {ProgramName} is saying hello {HelloCount} times");
/// <summary>
/// The program named {ProgramName} is saying hello {HelloCount} times
/// </summary>
/// <param name="logger">The logger to write to</param>
/// <param name="programName">The {ProgramName} message property</param>
/// <param name="helloCount">The {HelloCount} message property</param>
public static void SayHello(this ILogger<MyApp> logger, string programName, int helloCount)
{
_logSayHello.Log(logger, programName, helloCount);
}
}A LogMessage struct would be a allocation-free. Having implicit operators for tuples which match the constructor parameters enables field initialization without needing to repeat the generic argument types.
public struct LogMessage<T1, ..., Tn>
{
public LogMessage(LogLevel logLevel, EventId eventId, string formatString);
public LogMessage(LogLevel logLevel, int eventId, string formatString);
public LogMessage(LogLevel logLevel, string eventName, string formatString);
public LogLevel LogLevel {get;}
public EventId EventId {get;}
public string FormatString {get;}
public void Log(ILogger logger, T1 value1, ..., Tn valuen);
public void Log(ILogger logger, Exception exception, T1 value1, ..., Tn valuen);
public static implicit operator LogMessage<T1, ..., Tn>((LogLevel logLevel, EventId eventId, string formatString) parameters);
public static implicit operator LogMessage<T1, ..., Tn>((LogLevel logLevel, int eventId, string formatString) parameters);
public static implicit operator LogMessage<T1, ..., Tn>((LogLevel logLevel, string eventName, string formatString) parameters);
}Describe alternatives you've considered
Having one struct per LogLevel could also remove the need to have the first "LogLevel" argument in the initializer - but that would be a larger number of classes and intellisense wouldn't list the level names for you like typing LogLevel. does
Activity
davidfowl commentedon Nov 15, 2020
@lodejard After giving this a second look I wonder how much this matters given that the logger message is mostly used as an internal detail of these static methods. Would it be better to use a type as IDL for code generating one of these objects.
Then it would generate this:
Usage would look like:
We need partial extension methods @MadsTorgersen @jaredpar. I keep running into cases where small language limitations make the user experience slightly worse for source generators.
davidfowl commentedon Nov 15, 2020
cc @noahfalk @shirhatti
geeknoid commentedon Nov 16, 2020
I've been working on a model that uses C# 9.0 source generators. The developer writes:
And the following is generated automatically:
There is also an option to generate a wrapper type for the ILogger, rather than extension methods:
which comes out as:
I believe this code is both more convenient and more efficient than existing approaches used in the wild.
geeknoid commentedon Nov 17, 2020
Based on the example I provided above, I propose that we extend the framework with support to automatically generate strongly-type logging methods, based on a developer-authored interface type. The developer provides the minimum amount of information necessary, and the source generator produces a highly efficient set of logging methods. The generated logging methods can be produced as extension methods on ILogger, or can be produced as a type that wraps ILogger and exposes instance logging methods.
More specifically:
I haven't contributed to this project before, but I'd be happy to learn the ropes and get something like the above checked in.
82 remaining items
stephentoub commentedon Apr 10, 2021
Again, this wasn't saying "oh man this is going to be hard to implement so we shouldn't expose the API we want", this was saying "the generator handles all the logic so we can expose the API we want".
geeknoid commentedon Apr 10, 2021
Regarding:
The constraint is currently that the logging method cannot be generic. The type the method is in can be generic, and method arguments can be of generic types. But the method itself cannot be generic.
stephentoub commentedon Apr 10, 2021
What leads to that constraint being necessary? I'm unclear what it prevents that couldn't be achieved via a type parameter, which is currently allowed. Hence the suggestion that both should be allowed or disallowed equally. Is there a technical limitation?
geeknoid commentedon Apr 10, 2021
I don't believe there is a technical problem. I didn't see the value in a generic logging method so I didn't put the extra effort necessary to parse, emit, and test this code path. This seemed like something that could clearly be added in the future if a need ever arose. You wouldn't want to complicate the generator logic prematurely, right? :-)
I don't understand why it would be a good idea to say "if you can't do generic methods, than you shouldn't do generic types either". Those seem orthogonal. It's quite possible (and indeed common) for a logging method to be introduced in a generic type. But introducing a generic method by itself seems much less likely.
geeknoid commentedon Apr 10, 2021
In the implementation, there is a substantial difference in supporting generic types vs. generic methods. Generic types are mostly free. Support generic methods is more involved as the code generator needs to not only parse these definitions, it needs to generate a corresponding signature. So it would need to parse N generic definitions along with their corresponding constraints, and then emit them back out on the generated method.
stephentoub commentedon Apr 10, 2021
I feel like I need to reiterate again that, to my knowledge, no one has expressed concern about how complex the generator is.
It depends on the reason for the limitation. One reason, for example, would be that the purpose of such generated logging methods is to enforce strong typing on the structured logging, such that, for example, you could map each argument to its own database column or the like. A T parameter, regardless of whether it comes from a generic method parameter or a generic type parameter, could easily cause problems for that, with various calls each contributing their own unique type that then may not be supported by the underlying store. So if the concern were that, you'd want to ban it for any generic parameter, regardless of where it came from.
Instead, you've stated that's not the reason, but rather purely an artificial constraint based on viewed demand and the difficulty of implementation. In that case, sure, there's no reason to cut off the easier thing that "just works", though I expect in the fullness of time you'll want to remove the generic method limitation as well.
Really? We do that in a variety of places. For example, we want a semi-structured trace logging method that can be used to trace out messages in a large number of places, with some arguments fixed for all tracing sites and some variable, e.g.
where additionalData may be a value type. We don't want to add overloads for every possible T that additionalData may be, nor do we want to have to add guard calls around every use of Log to prevent additionalData from being boxed. If this
Logcould actually beLog<T>, it'd be simple to do. We could work around that by putting this Log method on its own generic type, and instead writeTracing<TypeOfAdditionalData>.Log(httpConnectionPool, httpConnection, "Trace: {0}", additionalData), but then we lose type inference and it's more code at every usage site, plus it ends up then needing to be separate from the rest of the logging methods we use.That said, for the particular cases where the above arises, we're much more likely to use C# 10 interpolation support, write:
and then not only avoid boxing for additionalData, but also not have to add a guard at the call site even if additionalData is actually a complicated and potentially expensive expression.
Which is to say, I believe there are uses, but we ourselves in the core libraries aren't going to use this mechanism, so this feedback is about use cases rather than strong need. If we can't support generic method parameters right now because it actually is too complex for the generator right now and we want to wait for further feedback before investing in it, that's reasonable.
Kahbazi commentedon Apr 10, 2021
Does the source generator only target the
LoggerMessageAttributeor does it also target attributes that inheritsLoggerMessageAttribute?A user could creates its own attribute which inherits from
LoggerMessageAttributeand set the constructor with whichever property that the application needs.maryamariyan commentedon Apr 10, 2021
Note that this is a sealed attribute
Kahbazi commentedon Apr 10, 2021
Oh I missed that. So I think what I'm asking is that could it not be sealed so the users could customize their own log attribute?
geeknoid commentedon Apr 10, 2021
@Kahbazi, I think that's an intriguing idea, but it wouldn't 'just work' I think. Given how attributes are parsed, there's dedicated logic that looks at ctor parameters or properties, etc. Maybe there's a way to write attribute parsing logic that is inherently more generic in nature and could tolerate inheritance like that, but that's now how the source generator works at the moment.
maryamariyan commentedon Apr 23, 2021
@lodejard the logging generator got submitted into Microsoft.Extensions.Logging package version 6.0.0-preview.4.21216.3,
and here is a Hello World type gist using it.