Hi-Res Logging in .NET Aspire Without Touching Business Code

by Metalama Team on 18 Dec 2024

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 and LogAllPublicMethodsFabric.

The aspects project is then referenced in both the web API and the web front-end projects.

{}

The full source code of examples in this article is available on .

When you run the app, you should see this:

Front-end with one finished and one unfinished task.

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:

.NET Aspire dashboard showing structured log of method calls.

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.

This article was first published on a https://blog.postsharp.net under the title Hi-Res Logging in .NET Aspire Without Touching Business Code.

Discover Metalama, the leading code generation and validation toolkit for C#

  • Write and maintain less code by eliminating boilerplate, generating it dynamically during compilation, typically reducing code lines and bugs by 15%.
  • Validate your codebase against your own rules in real-time to enforce adherence to your architecture, patterns, and conventions. No need to wait for code reviews.
  • Excel with large, complex, or old codebases. Metalama does not require you to change your architecture. Beyond getting started, it's at scale that it really shines.

Discover Metalama Free Edition

Related articles