-
Notifications
You must be signed in to change notification settings - Fork 4.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[API Proposal]: Introduce source generation based support for .NET metrics API #77516
Comments
I couldn't figure out the best area label to add to this issue. If you have write-permissions please help me learn by adding exactly one area label. |
@dpk83 - high level I like the idea of source generation for metrics similar to what exists for logging. I'm curious if you've considered or gotten feedback on designs where the source compiled APIs directly call the instrument APIs vs. returning the instrument to the user and requiring them to make the call? Here is what I imagine usage of the two different styles looks like: Static factories: static class MyHomeControllerMetrics
{
[Counter<long>(typeof(MyObject), Name="MyStrongTypedInstrument")
public static partial MyCounterInstrument CreateMyCounterInstrument(this IMeter meter);
[Counter<int>("clientRing", "clientType", "region", Name = "TestInstrument")]
public static partial TestInstrument CreateTestInstrument(this IMeter meter);
}
class MyHomeController
{
MyCounterInstrument _counter;
TestInstrument _test;
// ... additional instruments listed here
public MyHomeController()
{
IMeter meter = ...
_counter = MyHomeControllerMetrics.CreateMyCounterInstrument(meter);
_test = MyHomeControllerMetrics.CreateTestInstrument(meter);
// ... additional instruments inited here
}
public void DoSomeWork(MyObject obj)
{
_counter.Add(10, obj);
}
} Direct counter manipulation: partial class MyHomeControllerMetrics
{
[Counter(Name="MyStrongTypedInstrument")]
public partial void AddCounter(long increment, MyObject obj);
[Counter(Name="TestInstrument")]
public partial void AddTest(int increment, string clientRing, string clientType, string region);
}
class MyHomeController
{
MyHomeControllerMetrics _metrics;
public MyHomeController()
{
IMeter meter = ...
_metrics = new MyHomeControllerMetrics(meter);
}
public void DoSomeWork(MyObject obj)
{
_metrics.AddCounter(10, obj);
}
} I find the second one a little more appealing for a few reasons:
If there are situations that require creating or accessing the instrument I'm not opposed to it, but it didn't seem as streamlined for common usage. Although out-of-scope here, I'm also imagining further down a roadmap we might use source generation to help streamline emitting multiple types of telemetry without user code needing to cache different factory objects in fields for producing each kind of data they want to send: // this type could be wrapping any or all of {ILogger, Meter, ActivitySource }
partial class MyHomeControllerInstrumentation
{
[Counter(Name="MyStrongTypedInstrument")]
public partial void AddCounter(long increment, MyObject obj);
[LoggerMessage(Message="Doing the work {clientName}")]
public partial void LogDoWork(string clientName);
}
class MyHomeController
{
MyHomeControllerInstrumentation _instr;
public MyHomeController(MyHomeControllerInstrumentation instrumentation)
{
_instr = instrumentation;
}
public void DoSomeWork(MyObject obj)
{
_instr.LogDoWork(obj.ClientName);
_instr.AddCounter(10, obj);
...
}
} |
@noahfalk When we were designing the solution we did consider something down that path, however, the creation of the instrument object becomes really challenging there. As you didn't add where the creation of the actual counter is taking place based on the overall usage pattern I assume one of the following options.
|
I'm thinking of option 2 in your list above. I'm certainly inexperienced at implementing source generators so it is entirely possible I am overlooking obstacles, but so far in my experimentation the obstacles weren't apparent yet.
I'm not sure why this would be the case? Hasn't the class already been identified by the presence of well-known attributes on its methods? I wrote a crude generator that found all methods decorated with the CounterAttribute, determined what class they were contained in and then generated a constructor for that class without the need for a 2nd attribute.
I understand the mapping problem when you talked about option 1, but it was unclear how that problem applied in option 2. This is the user and generated code I was imagining: // user code
partial class MyHomeControllerMetrics
{
[Counter(Name="MyStrongTypedInstrument")]
public partial void AddCounter(long increment, MyObject obj);
[Counter(Name="TestInstrument")]
public partial void AddTest(int increment, string clientRing, string clientType, string region);
}
// generated code
partial class MyHomeControllerMetrics
{
Counter<long> _c1;
Counter<int> _c2;
public MyHomeControllerMetrics(Meter m)
{
_c1 = m.CreateCounter<long>("MyStrongTypedInstrument");
_c2 = m.CreateCounter<int>("TestInstrument");
}
public partial void AddCounter(long increment, MyObject obj)
{
//hand-waving the conversion of MyObject to tags because it is presumably the same
//as whatever R9 is doing now in the strong-typed instrument code
var tags = ...
_c1.Add(increment, tags);
}
public partial void AddTest(int increment, string clientRing, string clientType, string region)
{
_c2.Add(increment,
KeyValuePair.Create("clientRing", clientRing),
KeyValuePair.Create("clientType", clientType),
KeyValuePair.Create("region", region));
}
} Looking at the Logging generator for comparison the generator appears to understand both the class and list of attributed methods in that class when generating the source. I was assuming the algorithm to generate the source here is roughly:
Maybe I am overlooking major issues and I'm hoping you can help me understand what they are. Thanks! |
Yes, the presence of an attribute on the partial method in the class will let you modify the class but things get complicated as we are talking about the class containing multiple attributes all trying to modifying the class. i.e. A class can have a mix of multiple counters and histograms. While each attribute only modifies it's method now it's also modifying the containing class constructor. Multiple attributes can't coordinate during source generation so having multiple attributes trying to modifying their containing class constructor doesn't look right. e.g. In your example above each attribute will now need to add a call to create the counter/histogram in the class constructor, so each attribute will then try to create the constructor. As constructors can't be partial you will now add additional logic to check for the presence of constructor and then create one or use an existing one, or you entirely decide that you want to just create the instrument objects directly at the time of declaration. Another issue with this approach is the fact that all declared instruments are allocated regardless of whether there is any code path using the object. It's a minor point but I am just stating it to surface the limitations. @geeknoid Let us know your thoughts on the proposal once you are back next week. |
Sorry I'm still having trouble understanding where this restriction is coming from? When I look at the existing logger source generator the code path appears to generate the entire class in one operation and the code is aware of all attributed methods in the class simultaneously. Am I misunderstanding something about how the logger code works or is there a reason the counter generator can't work the same way that the logger generator does? Thanks for trying to explain and sorry if I am just missing something. |
My mistake. You are right, parser is the one where we process each attributes individually and create the collection of all processed attributes so Emitter has the full collection to help aid with source generation. Your proposal in general look fine but I have a few concerns
I am not opposed to your proposal. We had multiple iteration before we landed on what we have today in R9 but can't remember all the pros and cons of various approach now so trying to recall some of the issues. |
Thanks @dpk83! If you are able to find any of your notes or customer feedback from earlier iterations that could help give us a jump start. If not no worries, its still very useful to raise the pros/cons we see on the different designs so we can make sure to get feedback around it. As I was thinking on this more the last few days I realized I was overlooking the relative lifespans of the Meter and Instrument objects. The example usages I gave above suggested that Instruments were created once per controller, but Meter was created as a DI singleton and injected via constructor. Since controller instances are created new for every new web request this means Instruments would also be created for every request. Meter objects maintain an internal List of every Instrument they create so those examples I gave would have nasty memory leaks. Was that an issue you had looked into before? In the original design you have at the top of the thread is a new Instrument instance allocated every time |
We never released the other iterations to customers, they were more of our internal iterations as we were designing the apis. Most of the discussions were over meetings and in Teams chats so I will look for the pros/cons and share.
In our original design we returned cached instrument (where we were using R9's Meter APIs). We ported the source gen to the new .NET Meter API recently which is where we didn't port the caching logic as we thought your team may not want to cache it given the .net meter APIs always create a new Meter and counter while R9's meter APIs supported caching. Currently we have both the source gen and our customers are currently on the R9 meter APIs so are using the cached version and seems like we will have to add caching to the .NET Meter source gen as well to support migration to .NET meter without breaking their existing usage. |
Ah gotcha, that makes more sense now. If we are going to do instrument caching (which I am hesitant to do but its certainly on the table) I'm thinking it would be more efficient and consistent to do it inside Meter rather than in the source generator only. I'm guessing you'd be happy with that if we wound up going that route? Is this source the right place to be looking for R9's current caching behavior? Going back to some of the issues you raised above let me offer a few thoughts to see if we can find closer agreement, but its also fine if we don't. I'm hoping once we can solicit wider feedback we'll start seeing some majority preferences.
I wasn't expecting we'd see use-cases where large numbers of instruments had been defined in code but are never used. Did you see anything like that from R9's existing customers? I feel like this might be anti-pattern regardless because if they are defining so many instruments that the memory usage makes a measurable difference (1000+?), that probably means all the unused code loaded in memory is also making a measurable difference. One option to avoid this is to create the instruments on first use rather than in the constructor, but in return all users pay a tiny perf penalty for the extra conditional check.
This one I hadn't recognized, thanks for raising it! One potential solution would be code that looks like this: [Counter(Name="TestInstrument")]
public partial void AddTest(int increment,
[Tag(Name="client.Ring")] string clientRing,
[Tag(Name="client.Type")] string clientType,
string region); I agree it looks less nice than before though we may disagree on the amount less nice. To me this is only mildly less nice. Another alternative is to have the generated type expose the instrument like this: [Counter<int>("clientRing", "clientType", "region", Name = "TestInstrument")]
public TestInstrument TestInstrument {get;init;} I think of this as being somewhere in the middle of the existing proposals. The attribute is identical to the static factory approach, but instrument is still being cached locally in a field and the Meter does not need to provided at the callsite. When you do the static factory design do you assume that all tags are string typed objects or is there some way in the attribute to specify that certain tags have a non-string type?
This feels like we shifted where the choice from N elements is made, but both designs still require the dev to make that choice at some point. In the static factory case the developer will need to pick from one of the N different CreateXYZ() methods when typing |
Yes
Agree it's a minor point and I don't expect services to be doing it in general.
This actually looks nice, the only concern is that user will need to create different class for each meter object, i.e. if there are 10 different meter objects then a user will need to create 10 different meter classes. On the other hand I don't expect services to be using a lot of different meter instances.
Yes, we currently assume string by default because the metric backends only work with string tags (our metering implementation used strings so did the source gen), but that can easily be changed to accept |
With the static factory approach was it considered good or common practice to pass different Meters into different factory functions defined on the same type. To me this pattern seems like it could easily set the developer up to unintentionally pass the wrong Meter. // Make sure to only call these functions with the Foo Meter
[Counter<long>(typeof(MyObject), Name="MyStrongTypedInstrument")
public static partial MyCounterInstrument CreateMyCounterInstrument(this IMeter meter);
...
// Make sure to only call these functions with the Bar Meter
[Counter<int>("clientRing", "clientType", "region", Name = "TestInstrument")]
public static partial TestInstrument CreateTestInstrument(this IMeter meter);
...
I was more thinking that since the design was producing strongly typed instruments it felt a little odd that it wasn't enforcing strong typing on the tags. For example clientType sounds like it might be represented by an enum in the code and strong typing could make it clear to the developer that they must specify an enum value when passing that argument. However I can also imagine that non-string tags may be uncommon and it makes the attributes more verbose if the developer is required to enumerate the types for all tags. |
It is a problem but wasn't a big concern because we expected the instrument creation will take part once during initialization (mostly in constructors) so developers will pass the required meter object.
Specifying type for each tag would have compromised the usability as well as the fact that we have to ultimately convert it to string it would result in boxing which we definitely want to avoid as much as possible for performance reason here. |
@dpk83 per offline discussions I have moved this to future milestone out of .NET 8.0. |
Background and motivation
Our team creates Telemetry framework for internal teams and we have created metrics solution. It was created before .NET Meter API came into existence so we created our own IMeter API surface. In our internal project, everything is designed to be highly efficient (consumes less CPU and reducing allocation as much as possible) while providing an easy to use developer friendly API surface. This helps reduce the developer burden, reduces chances of mistakes and at the same time helps gain the performance benefits.
With that in mind we utilized the compile time source generation of roslyn and introduced attributes for various instruments. Here is an example of how the attributes look like in our internal project using our IMeter surface
Developer writes this signature using Counter attribute and we would generate the source code for this method underneath which generates a highly efficient source using the most efficient mechanism available to achieve the required operations.
Once the above signature is defined, developers can then use it to create the typed counter like this:
Similarly, these attributes also support strong typed objects e.g.
You can then use it as
The underlying code generated at the compile time would expand this object's properties into individual dimension, thus simplifying the work for developers.
We are extending this source generation to support .NET Meter API and would like to have this become part of .NET so it can be useful for the broader community.
With the code generation, we have possibilities to provide the highest performance. E.g. we could automatically use the TagList if the total tags are less than or equal to 8 and switch to expanded tag list when there are more. We can skip all the processing upfront if there are no listeners or the instrument is not enabled etc.
We have found this extremely useful and so did our customers. We can build additional functionality on top where we could add a source generator to generate reports of the metrics emitted by a service and all it's dependent libraries (so developers know upfront what metrics can be emitted by libraries that they depend on, which ones are enabled vs which ones are not etc.)
API Proposal
Similarly for HistogramAttribute
API Usage
Similarly, these attributes also support strong typed objects e.g.
Alternative Designs
No response
Risks
No response
The text was updated successfully, but these errors were encountered: