System.Diagnostics.Metrics is the standard instrumentation interface of .NET. An application creates a Meter, creates instruments such as Counter<T> from that meter, and updates the instruments from its own code. Collection agents, including OpenTelemetry exporters, then read those instruments.
Instrumenting a method by hand means declaring a field for each instrument, creating the instrument in a constructor, naming it consistently, and updating it in the method body. For a type with several instrumented methods and several measurements per method, most of that code is repetitive.
This example replaces the repetitive part with three method-level aspects. The following code shows a method that counts its calls, measures its execution time and counts the exceptions it throws, and the code that the aspects generate from it.
1namespace Metalama.Samples.Metrics.Demo;
2
3public class HatShop
4{
5 private int _orderCount;
6
7 [MeasureExecutionCount]
8 [MeasureExecutionTime]
9 [MeasureExceptionCount]
10 public void PlaceOrder()
11 {
12 this._orderCount++;
13
14 if ( this._orderCount % 10 == 0 )
15 {
16 throw new InvalidOperationException( "The hat is out of stock." );
17 }
18
19 Console.WriteLine( "Ordering a hat." );
20 }
21}
22
1using System.Diagnostics;
2using System.Diagnostics.Metrics;
3using Metalama.Framework.RunTime;
4using Metrics;
5
6namespace Metalama.Samples.Metrics.Demo;
7
8public class HatShop
9{
10 private int _orderCount;
11
12 [MeasureExecutionCount]
13 [MeasureExecutionTime]
14 [MeasureExceptionCount]
15 public void PlaceOrder()
16 {var timestamp = Stopwatch.GetTimestamp();
17 try
18 {
19 (_hatShopMetrics?.PlaceOrderExecutionCount).Add(1);
20 try
21 {
22 this._orderCount++;
23
24 if ( this._orderCount % 10 == 0 )
25 {
26 throw new InvalidOperationException( "The hat is out of stock." );
27 }
28
29 Console.WriteLine( "Ordering a hat." );
30}
31 catch
32 {
33 (_hatShopMetrics?.PlaceOrderExceptionCount).Add(1);
34 throw;
35 }
36
37 return;
38 }
39 finally
40 {
41 (_hatShopMetrics?.PlaceOrderExecutionTime).Add(Stopwatch.GetTimestamp() - timestamp);
42 }
43 }
44 private HatShopMetrics? _hatShopMetrics;
45
46 public HatShop([AspectGenerated] HatShopMetrics? hatShopMetrics = null)
47 {
48 this._hatShopMetrics = hatShopMetrics;
49 }
50}
51
The aspects generate a second type as well, named after the instrumented type, which holds the instruments and obtains the meter through dependency injection.
1using System.Diagnostics.Metrics;
2using System.Reflection;
3using Metalama.Framework.RunTime;
4using Metalama.Samples.Metrics;
5using Metalama.Samples.Metrics.Demo;
6
7namespace Metrics
8{
9 public class HatShopMetrics
10 {
11 internal readonly Counter<long> PlaceOrderExceptionCount;
12 internal readonly Counter<long> PlaceOrderExecutionCount;
13 internal readonly Counter<long> PlaceOrderExecutionTime;
14 private IMeterFactory _meterFactory;
15 private IMetricHost _metricHost;
16
17 public HatShopMetrics([AspectGenerated] IMeterFactory? meterFactory = null, [AspectGenerated] IMetricHost? metricHost = null)
18 {
19 this._meterFactory = meterFactory ?? throw new System.ArgumentNullException(nameof(meterFactory)); this._metricHost = metricHost ?? throw new System.ArgumentNullException(nameof(metricHost));
20 var meter = _meterFactory.Create(_metricHost.ApplicationName, _metricHost.ApplicationVersion, _metricHost.Tags);
21 this.PlaceOrderExceptionCount = meter.CreateCounter<long>("PlaceOrder.ExceptionCount");
22 _metricHost.RegisterInstrument(this.PlaceOrderExceptionCount, ((MethodInfo)(typeof(HatShop).GetMethod("PlaceOrder", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null) ?? throw new MissingMethodException("The method 'HatShop.PlaceOrder()' could not be found using reflection."))), "ExceptionCount");
23 this.PlaceOrderExecutionCount = meter.CreateCounter<long>("PlaceOrder.ExecutionCount");
24 _metricHost.RegisterInstrument(this.PlaceOrderExecutionCount, ((MethodInfo)(typeof(HatShop).GetMethod("PlaceOrder", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null) ?? throw new MissingMethodException("The method 'HatShop.PlaceOrder()' could not be found using reflection."))), "ExecutionCount");
25 this.PlaceOrderExecutionTime = meter.CreateCounter<long>("PlaceOrder.ExecutionTime");
26 _metricHost.RegisterInstrument(this.PlaceOrderExecutionTime, ((MethodInfo)(typeof(HatShop).GetMethod("PlaceOrder", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null) ?? throw new MissingMethodException("The method 'HatShop.PlaceOrder()' could not be found using reflection."))), "ExecutionTime");
27 }
28 }
29}
Architecture of the example
The example is built from two kinds of aspects that cooperate.
| Component | Role |
|---|---|
MetricAttribute and its three derived classes |
Method-level aspects that declare what to measure and supply the templates that measure it. |
ImplementMetricsAspect |
A type-level aspect that generates the companion metrics class and overrides the instrumented methods. |
GenerateAddMetricsExtensionAttribute |
An assembly-level aspect that generates the dependency injection registration of every generated metrics class. |
The division of work matters. A method-level aspect sees one method, so it cannot create a type that gathers the instruments of all methods. A type-level aspect sees the whole type, but nothing tells it which measurements were requested. The two are connected by the aspect requirement mechanism described in Adding child aspects.
Step 1. Declaring a measurement
The base class of the three metric aspects adds no advice of its own. Its BuildAspect method only requires the type-level aspect on the declaring type.
1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3
4namespace Metalama.Samples.Metrics;
5
6public abstract class MetricAttribute : MethodAspect
7{
8 public override void BuildAspect( IAspectBuilder<IMethod> builder )
9 {
10 builder.Outbound.Select( m => m.DeclaringType ).RequireAspect<ImplementMetricsAspect>();
11 }
12
13 [Template]
14 internal abstract dynamic? OverrideMethodTemplate( IField metricsField, IFieldOrProperty metricProperty );
15
16 [Template]
17 internal virtual void CreateMetricTemplate(
18 IExpression meter,
19 IFieldOrProperty metricProperty,
20 [CompileTime] string metricName )
21 {
22 // Must be overridden.
23 }
24
25 internal abstract string MetricKind { get; }
26
27 internal abstract Type MetricType { get; }
28}
The class declares two templates and two compile-time properties, but it never applies them. It acts as a template provider: ImplementMetricsAspect reads MetricKind and MetricType to name and type the instrument, then uses OverrideMethodTemplate to override the method and CreateMetricTemplate to create the instrument.
The three derived classes supply those templates. Counting the calls of a method takes one statement before the original implementation.
1using System.Diagnostics.Metrics;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Code.Invokers;
5
6namespace Metalama.Samples.Metrics;
7
8public class MeasureExecutionCountAttribute : MetricAttribute
9{
10 internal override dynamic? OverrideMethodTemplate( IField metricsField, IFieldOrProperty metricProperty )
11 {
12 var meterExpression = metricProperty.WithObject( metricsField ).WithOptions( InvokerOptions.NullConditional );
13
14 ((Counter<long>) meterExpression.Value!).Add( 1 );
15
16 return meta.Proceed();
17 }
18
19 internal override void CreateMetricTemplate(
20 IExpression meter,
21 IFieldOrProperty metricProperty,
22 [CompileTime] string metricName )
23 {
24 metricProperty.Value = ((Meter) meter.Value!).CreateCounter<long>( metricName );
25 }
26
27 internal override string MetricKind => "ExecutionCount";
28
29 internal override Type MetricType => typeof(Counter<long>);
30}
metricProperty.WithObject( metricsField ).WithOptions( InvokerOptions.NullConditional ) builds the expression that reads the instrument from the companion object. NullConditional makes the generated expression use the ?. operator, because the companion object is an optional dependency and can be null.
Measuring execution time requires a try and finally block around the original implementation.
1using System.Diagnostics;
2using System.Diagnostics.Metrics;
3using Metalama.Framework.Aspects;
4using Metalama.Framework.Code;
5using Metalama.Framework.Code.Invokers;
6
7namespace Metalama.Samples.Metrics;
8
9public class MeasureExecutionTimeAttribute : MetricAttribute
10{
11 internal override dynamic? OverrideMethodTemplate( IField metricsField, IFieldOrProperty metricProperty )
12 {
13 var meterExpression = metricProperty.WithObject( metricsField ).WithOptions( InvokerOptions.NullConditional );
14
15 var timestamp = Stopwatch.GetTimestamp();
16
17 try
18 {
19 return meta.Proceed();
20 }
21 finally
22 {
23 ((Counter<long>) meterExpression.Value!).Add( Stopwatch.GetTimestamp() - timestamp );
24 }
25 }
26
27 internal override void CreateMetricTemplate(
28 IExpression meter,
29 IFieldOrProperty metricProperty,
30 [CompileTime] string metricName )
31 {
32 metricProperty.Value = ((Meter) meter.Value!).CreateCounter<long>( metricName );
33 }
34
35 internal override string MetricKind => "ExecutionTime";
36
37 internal override Type MetricType => typeof(Counter<long>);
38}
The measured value is the difference between two GetTimestamp readings, so the counter accumulates stopwatch ticks, not milliseconds. The consumer of the metric has to divide by Frequency to obtain seconds.
Counting exceptions requires a catch block that rethrows.
1using System.Diagnostics.Metrics;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Code.Invokers;
5
6namespace Metalama.Samples.Metrics;
7
8public class MeasureExceptionCountAttribute : MetricAttribute
9{
10 internal override dynamic? OverrideMethodTemplate( IField metricsField, IFieldOrProperty metricProperty )
11 {
12 var meterExpression = metricProperty.WithObject( metricsField ).WithOptions( InvokerOptions.NullConditional );
13
14 try
15 {
16 return meta.Proceed();
17 }
18 catch
19 {
20 ((Counter<long>) meterExpression.Value!).Add( 1 );
21
22 throw;
23 }
24 }
25
26 internal override void CreateMetricTemplate(
27 IExpression meter,
28 IFieldOrProperty metricProperty,
29 [CompileTime] string metricName )
30 {
31 metricProperty.Value = ((Meter) meter.Value!).CreateCounter<long>( metricName );
32 }
33
34 internal override string MetricKind => "ExceptionCount";
35
36 internal override Type MetricType => typeof(Counter<long>);
37}
Step 2. Generating the metrics class
The type-level aspect does the rest of the work.
12public override void BuildAspect( IAspectBuilder<INamedType> builder )
13{
14 base.BuildAspect( builder );
15
16 // Create the Metrics type.
17 var metricsType = builder
18 .With( builder.Target.Compilation )
19 .WithNamespace( "Metrics" )
20 .IntroduceClass(
21 builder.Target.Name + "Metrics",
22 whenExists: OverrideStrategy.New,
23 buildType: t => t.Accessibility = Accessibility.Public );
24
25 metricsType.IntroduceConstructor( nameof(this.MetricsConstructorTemplate) );
26
27 // Pull dependencies into the metric type.
28 var meterFactoryField = metricsType.IntroduceDependency( typeof(IMeterFactory) ).Declaration;
29 var meterHostField = metricsType.IntroduceDependency( typeof(IMetricHost) ).Declaration;
30
31 // Introduce a dependency to the Metrics type into the target type.
32 var metricsField = builder.IntroduceDependency(
33 metricsType.Declaration.ToNullable(),
34 new DependencyOptions() { IsRequired = false } )
35 .Declaration;
36
37 // Iterate metrics and simultaneously build the Metrics type and override the instrumented methods.
38 var metrics = new List<MetricMetadata>();
39
40 var predecessors = builder.AspectInstance.Predecessors
41 .Select( p => (IAspectInstance) p.Instance )
42 .Select( i => (Aspect: (MetricAttribute) i.Aspect,
43 TargetMethod: (IMethod) i.TargetDeclaration.GetTarget()) )
44 .OrderBy( x => x.TargetMethod )
45 .ThenBy( x => x.Aspect
46 .MetricKind ); // TODO: It would be better to order by aspect execution order instead of alphabetical name, but we don't have access to this data.
47
48 foreach ( var predecessor in predecessors )
49 {
50 var fieldName = predecessor.TargetMethod.Name + predecessor.Aspect.MetricKind;
51 var metricName = predecessor.TargetMethod.Name + "." + predecessor.Aspect.MetricKind;
52
53 // Introduce a field to store the metric.
54 var metricProperty = metricsType
55 .IntroduceField(
56 fieldName,
57 predecessor.Aspect.MetricType,
58 IntroductionScope.Instance,
59 buildField: p =>
60 {
61 p.Accessibility = Accessibility.Internal;
62 p.Writeability = Writeability.ConstructorOnly;
63 } )
64 .Declaration;
65
66 // Override the method.
67 builder.With( predecessor.TargetMethod )
68 .WithTemplateProvider( predecessor.Aspect )
69 .Override(
70 nameof(predecessor.Aspect.OverrideMethodTemplate),
71 args: new { metricsField, metricProperty } );
72
73 // Remember the field metadata to advice the constructor.
74 metrics.Add(
75 new MetricMetadata(
76 predecessor.TargetMethod,
77 predecessor.Aspect,
78 metricProperty,
79 metricName,
80 metrics.Count ) );
81 }
82
83 metricsType.AddInitializer(
84 nameof(this.InitializeMetrics),
85 InitializerKind.BeforeInstanceConstructor,
86 args: new { metrics, meterFactoryField, meterHostField } );
87
88 builder.With( builder.Target.Compilation )
89 .AddAnnotation( new MetricTypeAnnotation( metricsType.Declaration.ToRef() ) );
90}
91It performs five operations.
It introduces the companion class in the Metrics namespace, named after the target type. whenExists: OverrideStrategy.New keeps the aspect usable when a class of that name already exists.
It pulls two dependencies into the companion class, IMeterFactory and IMetricHost, and introduces a dependency on the companion class itself into the instrumented type. The last one is declared with IsRequired = false, so the instrumented type keeps working when no metrics are configured. Dependency injection is provided by the Metalama.Extensions.DependencyInjection package, described in Injecting dependencies into aspects.
It reads builder.AspectInstance.Predecessors to find the method-level aspects that required it. Each predecessor gives the aspect instance and the method it was applied to, which is the information a type-level aspect cannot obtain any other way.
For each predecessor, it introduces a field in the companion class, then overrides the method with the template of that predecessor. WithTemplateProvider( predecessor.Aspect ) is the call that makes the method aspect's own template run under the type aspect.
It adds an annotation to the compilation that carries a reference to the generated class. The annotation is the channel to the assembly-level aspect of the next step, and the mechanism is described in Sharing state with advice.
Step 3. Creating the instruments
The constructor of the companion class is generated by the following template.
95[Template]
96private void InitializeMetrics( List<MetricMetadata> metrics, IField meterFactoryField, IField meterHostField )
97{
98 var meter = ((IMeterFactory) meterFactoryField.Value!).Create(
99 ((IMetricHost) meterHostField.Value!).ApplicationName,
100 ((IMetricHost) meterHostField.Value!).ApplicationVersion,
101 ((IMetricHost) meterHostField.Value!).Tags );
102
103 foreach ( var metric in metrics )
104 {
105 metric.Aspect.CreateMetricTemplate(
106 ExpressionFactory.Capture( meter ),
107 metric.MetricProperty,
108 metric.MetricName );
109
110 ((IMetricHost) meterHostField.Value).RegisterInstrument(
111 metric.MetricProperty.Value,
112 metric.Method.ToMethodInfo(),
113 metric.Aspect.MetricKind );
114 }
115}This template is the most instructive part of the example, because it calls another aspect's template. The foreach loop is compile-time, and inside it metric.Aspect.CreateMetricTemplate( ... ) expands the template of the metric aspect at that point of the generated constructor. The technique is described in Calling auxiliary templates.
The result is that the type-level aspect decides where the instrument is created, while each metric aspect decides how it is created. A new kind of measurement can be added by deriving from MetricAttribute alone, without changing ImplementMetricsAspect.
The host of the metrics is abstracted by the following interface. It supplies the identity of the application to the meter factory and receives each instrument as it is created, which lets an application keep its own inventory of instruments.
1using System.Diagnostics.Metrics;
2using System.Reflection;
3
4namespace Metalama.Samples.Metrics;
5
6public interface IMetricHost
7{
8 string ApplicationName { get; }
9
10 string? ApplicationVersion { get; }
11
12 IEnumerable<KeyValuePair<string, object?>> Tags { get; }
13
14 void RegisterInstrument( Instrument instrument, MethodInfo method, string metricKind );
15}
Step 4. Registering the generated classes
Each generated metrics class is resolved through dependency injection, so each one needs to be registered. The assembly-level aspect generates that registration code.
1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Microsoft.Extensions.DependencyInjection;
4
5namespace Metalama.Samples.Metrics;
6
7public class GenerateAddMetricsExtensionAttribute : CompilationAspect
8{
9 public override void BuildAspect( IAspectBuilder<ICompilation> builder )
10 {
11 var type = builder
12 .WithNamespace( "Metrics" )
13 .IntroduceClass(
14 "MetricRegistrations",
15 buildType: t =>
16 {
17 t.IsStatic = true;
18 t.Accessibility = Accessibility.Public;
19 } );
20
21 type.IntroduceMethod( nameof(AddAutoGeneratedMetrics) );
22 }
23
24 [Template]
25 public static void AddAutoGeneratedMetrics( [This] ServiceCollection serviceCollection )
26 {
27 foreach ( var annotation in meta.Target.Compilation.Enhancements().GetAnnotations<MetricTypeAnnotation>() )
28 {
29 var targetType = annotation.MetricType.GetTarget();
30 serviceCollection.AddSingleton( (Type) targetType.ToTypeOfExpression().Value! );
31 }
32 }
33}
It is applied once for the whole project.
1using Metalama.Samples.Metrics;
2
3[assembly: GenerateAddMetricsExtension]
4
The template reads back the annotations added in step 2, so the generated method lists exactly the classes that were generated in the current project. [This] turns the first parameter into the receiver of an extension method, so the generated code is called as serviceCollection.AddAutoGeneratedMetrics().
For the example above, the generated code is the following.
1using Microsoft.Extensions.DependencyInjection;
2
3namespace Metrics
4{
5 public static class MetricRegistrations
6 {
7 public static void AddAutoGeneratedMetrics(this ServiceCollection serviceCollection)
8 {
9 serviceCollection.AddSingleton(typeof(HatShopMetrics));
10 }
11 }
12}
Limitations
The order in which the measurements are nested around the method body is the alphabetical order of MetricKind, not the aspect order declared with AspectOrderAttribute. The predecessors are sorted by target method and then by metric kind, because the execution order of the predecessors is not exposed by builder.AspectInstance.Predecessors. The nesting is therefore deterministic but arbitrary, and a measurement that must be the outermost one cannot be placed there. Aspect ordering is described in Ordering aspects.
The generated registration method takes ServiceCollection, the concrete class, rather than IServiceCollection. It cannot be called on a service collection exposed through the interface.
The three aspects all create a Counter<long>. Execution time would be better served by a histogram, which records the distribution of the measurements rather than their sum.
This example provides the IMetricHost contract but no implementation of it, and no runnable program. The implementation is where an application decides which meter identity to report and what to do with the instruments.