Hi-Res Logging in .NET Aspire Without Touching Business Code
This article will explore how to trace method calls in a .NET Aspire app without boilerplate code using Metalama. It uses a base example of a to-do list app with an ASP.NET Core Minimal API backend and a Blazor front-end, orchestrated using .NET Aspire. During the article, we’ll demonstrate how to log all public methods of the app using the [Log]
aspect with a special kind of class called a fabric. The article will also show how to analyze the logs using the .NET Aspire dashboard.
Introduction
There’s a saying that computers always do what you tell them to do, which isn’t necessarily what you want them to do. When you find yourself in such a situation and it’s not clear where the difference originates, you have two options:
- Debugging requires the app to be interrupted and it may not always be possible to attach to the failing instance or reproduce the behavior with another one.
- Tracing allows you to follow the execution of the app without interrupting it, providing a detailed log of what happened.
In this article, we’ll explore how to trace method calls in a .NET Aspire app without boilerplate code using Metalama.
As we discussed in the article about caching in .NET Aspire, .NET Aspire is a development platform that simplifies building distributed cloud-native applications. It manages all the wiring between the various services of your application and other components like databases, messaging, and caching. Using Open Telemetry, it provides essential features for the observability of your app, such as logging, tracing, and metrics.
Step 1. Setting up the projects
To show you how to trace method calls in a .NET Aspire app, we’ll use a base example of a to-do list app. The app consists of an ASP.NET Core Minimal API backend and a Blazor front-end, orchestrated using .NET Aspire. The app is structured as a solution with multiple projects, each serving a different purpose.
The solution contains the following projects:
- The app host project, containing the service orchestration. You can think of this project as the bootstrapper of other projects.
- The web API project,
- The web front-end project,
- The service default project that contains shared service configuration,
- The data project shared between the API and front-end projects,
- The aspects project mentioned above implementing the
LogAttribute
andLogAllPublicMethodsFabric
.
The aspects project is then referenced in both the web API and the web front-end projects.
When you run the app, you should see this:
Step 2. Creating a logging aspect
An aspect is a special kind of class, usually a custom attribute, that executes within the compiler or the IDE and dynamically transforms your source code. There are several aspect frameworks for .NET. In this article, we’ll use the most modern of all: Metalama.
We will use the example logging aspect of the reference documentation.
Let’s see how we can use the [Log]
aspect in real code:
public partial class TodoApiClient( HttpClient httpClient )
{
public async Task<Todo[]?> GetTodosAsync( CancellationToken cancellationToken = default )
=> await httpClient.GetFromJsonAsync<Todo[]>( "/todo", cancellationToken );
}
Under the hood, the [Log]
aspect:
- Pulls the ILogger service into your class,
- Logs the method entry, including the argument values,
- Logs the method exit, including the returned value and output parameter values,
- Logs the method failure, including the exception message.
The source code is transformed into this before it is compiled into assembly instructions:
public partial class TodoApiClient
{
private readonly ILogger _logger;
private readonly HttpClient httpClient;
public TodoApiClient(HttpClient httpClient, ILogger<TodoApiClient> )
{
this.httpClient = httpClient;
this._logger = logger;
}
public async Task<Todo[]?> GetTodosAsync( CancellationToken cancellationToken = default )
{
var isTracingEnabled = _logger.IsEnabled(LogLevel.Trace);
if (isTracingEnabled)
{
using (var guard = LoggingRecursionGuard.Begin())
{
if (guard.CanLog)
{
_logger.LogTrace($"TodoApiClient.GetTodosAsync({{cancellationToken}}) started.",
[cancellationToken]!);
}
}
}
try
{
var result = await this.GetTodosAsync_Source(cancellationToken);
if (isTracingEnabled)
{
using (var guard_1 = LoggingRecursionGuard.Begin())
{
if (guard_1.CanLog)
{
_logger.LogTrace($"TodoApiClient.GetTodosAsync({{cancellationToken}}) returned {{result}}.",
[cancellationToken, result]!);
}
}
}
return result;
}
catch (Exception e) when (_logger.IsEnabled(LogLevel.Warning))
{
using (var guard_2 = LoggingRecursionGuard.Begin())
{
if (guard_2.CanLog)
{
_logger.LogWarning($"TodoApiClient.GetTodosAsync({{cancellationToken}}) failed: {e.Message}",
[cancellationToken]!);
}
}
throw;
}
}
}
This code is arguably very verbose, but you don’t have to write it by hand! To make it more compact, you could use some string interpolation techniques.
Step 3. Adding logging to all public methods
In the previous step, we’ve shown how to add logging without almost any change in the source code. However, you still had to add a custom attribute to every single method. This is still quite cumbersome!
It would be awesome to have some sort of SQL that would allow me to “select” all the public methods and then iterate over the result to apply the [Log] aspect… no? This is where the fabrics come in.
In a nutshell, a fabric is a feature of Metalama that allows you to apply aspects in bulk. In the next example, we’re going to use the transitive project fabric. A transitive fabric is a build-time entry point that is executed when any project referencing the project containing that fabric is built. The same works, of course, at design time.
Here is the code of the fabric:
internal class LogAllPublicMethodsFabric : TransitiveProjectFabric
{
public override void AmendProject( IProjectAmender amender )
=> amender
.SelectTypes()
.Where( type => type.Accessibility == Accessibility.Public )
.SelectMany( type => type.Methods )
.Where( method => method.Accessibility == Accessibility.Public && method.Name != "ToString" )
.AddAspectIfEligible<LogAttribute>();
}
The previous code snippet shows a fabric that adds the LogAttribute
aspect (that’s the name of the class that implements the [Log]
aspect) to all public methods (except ToString
). Since we’re adding the transitive fabric to the TodoList.Aspects
project, all projects referencing TodoList.Aspects
now have logging on their public methods.
This approach works for classes that are instantiated by the .NET dependency injection container because it relies on the ILogger service. You will have to pass this dependency manually to classes that are instantiated manually, without the DI container.
The fabric can be tuned for a different set of methods: all information about all methods is available, so your filter can be as fine-grained as you need.
Step 4. Analyzing the logs
.NET Aspire comes with a handy dashboard that includes a tab showing structured logs. There, you can see all the logs of all the services of your application. To check that our method calls are being logged (and the fabric and aspect work), we’ve added some tasks to the to-do list app and marked one of them as completed.
Back to the .NET Aspire dashboard, on the “Structured” tab, the log records can be filtered according to any parameter, including a trace ID, so you can view individual requests. The next screenshot shows all the filtered log records coming from our TodoService
class of the API service:
The values shown on the dashboard can be further improved using a custom log record processor, as we did in our sample, or by wiring up a logging framework with better support for structured logging of .NET objects like Serilog.
Summary
In this article, we’ve shown how to trace method calls in a .NET Aspire app without boilerplate code using Metalama. We’ve used a base example of a to-do list app with an ASP.NET Core Minimal API backend and a Blazor front-end, orchestrated using .NET Aspire. We’ve demonstrated how to log all public methods of the app using the [Log]
aspect using a special kind of class called a fabric. We’ve also shown how to analyze the logs using the .NET Aspire dashboard.