Featured image of post Programmatically monitoring and reacting to resource logs in .NET Aspire

Programmatically monitoring and reacting to resource logs in .NET Aspire

Combining IDistributedApplicationLifecycleHook, ResourceNotificationService, and ResourceLoggerService allows you to react to any changes in resources and their logs.

I was recently asked if it is possible to obtain the ID of a container orchestrated by .NET Aspire to monitor its logs. The answer is yes, and in this article, we will see two ways to achieve this by retrieving the logs of an arbitrary MongoDB resource named mongo and displaying them in the console. The first method will be specific to containers, while the second method can be applied to any resource.

var builder = DistributedApplication.CreateBuilder(args);

builder.AddMongoDB("mongo");

builder.Build().Run();

For the first method, it is necessary to implement an IDistributedApplicationLifecycleHook where we will put the logic to intercept the container ID. Injecting the ResourceNotificationService provided by .NET Aspire will allow us to receive notifications of changes made to resources during their execution. We will then filter these notifications to keep only those concerning our mongo resource.

As you will see in the code below, each resource notification is associated with a snapshot that represents the current state of the resource. It contains a property bag in which .NET Aspire, once the container has started, stores the container ID with a well-known property name container.id. This property is then displayed on the dashboard.

Once in possession of the container ID, we can use the Docker.DotNet library to connect to the Docker API and retrieve the logs.

Without further ado, here is the code for the distributed lifecycle hook for this first method. Don’t forget to register it in the distributed application builder services:

builder.Services.AddLifecycleHook<GetMongoContainerLogsLifecycleHook>();

internal sealed class GetMongoResourceLogsLifecycleHook(
    ResourceNotificationService resourceNotificationService,
    ILogger<GetMongoResourceLogsLifecycleHook> logger) : IDistributedApplicationLifecycleHook, IAsyncDisposable
{
    private readonly CancellationTokenSource _shutdownCts = new();
    private Task? _getMongoResourceLogsTask;

    public Task AfterResourcesCreatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
    {
        this._getMongoResourceLogsTask = this.GetMongoResourceLogsAsync(this._shutdownCts.Token);
        return Task.CompletedTask;
    }

    private async Task GetMongoResourceLogsAsync(CancellationToken cancellationToken)
    {
        try
        {
            string? mongoContainerId = null;
            await foreach (var notification in resourceNotificationService.WatchAsync(cancellationToken))
            {
                if (notification.Resource.Name == "mongo" &&
                    notification.Snapshot.Properties.FirstOrDefault(x => x.Name == "container.id") is { Value: string { Length: > 0 } containerId })
                {
                    mongoContainerId = containerId;
                    break;
                }
            }

            if (mongoContainerId is not null)
            {
                using var dockerClient = new DockerClientConfiguration().CreateClient();

                var logParameters = new ContainerLogsParameters { Timestamps = true, Follow = true, ShowStderr = true, ShowStdout = true };
                await dockerClient.Containers.GetContainerLogsAsync(mongoContainerId, logParameters, cancellationToken, new Progress<string>(line =>
                {
                    logger.LogInformation("{MongoOutput}", line);
                }));
            }
        }
        catch (OperationCanceledException)
        {
            // Expected when the application is shutting down.
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "An exception occurred while getting the logs for the mongo resource");
        }
    }

    public async ValueTask DisposeAsync()
    {
        await this._shutdownCts.CancelAsync();

        if (this._getMongoResourceLogsTask is not null)
        {
            try
            {
                await this._getMongoResourceLogsTask;
            }
            catch (OperationCanceledException)
            {
            }
        }

        this._shutdownCts.Dispose();
    }
}

It is important to note that once the support for restarting resources from the dashboard is implemented in a future version of .NET Aspire, this code will need to be adapted to handle potential restarts of the mongo resource.

This method of retrieving logs is restrictive because it is limited to containers and requires the third-party library Docker.DotNet, although it is an official NuGet package hosted under the dotnet GitHub organization. There is another way to retrieve resource logs, and it uses the ResourceLoggerService, a service also provided by .NET Aspire that exposes log streams of different resources. Here is the code for this second method:

builder.Services.AddLifecycleHook<GetMongoContainerLogsLifecycleHook>();

internal sealed class GetMongoResourceLogsLifecycleHook(
    ResourceNotificationService resourceNotificationService,
    ResourceLoggerService resourceLoggerService,
    ILogger<GetMongoResourceLogsLifecycleHook> logger) : IDistributedApplicationLifecycleHook, IAsyncDisposable
{
    private readonly CancellationTokenSource _shutdownCts = new();
    private Task? _getMongoResourceLogsTask;

    public Task AfterResourcesCreatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
    {
        this._getMongoResourceLogsTask = this.GetMongoResourceLogsAsync(this._shutdownCts.Token);
        return Task.CompletedTask;
    }

    private async Task GetMongoResourceLogsAsync(CancellationToken cancellationToken)
    {
        try
        {
            string? mongoResourceId = null;
            await foreach (var notification in resourceNotificationService.WatchAsync(cancellationToken))
            {
                if (notification.Resource.Name == "mongo")
                {
                    mongoResourceId = notification.ResourceId;
                    break;
                }
            }

            if (mongoResourceId is not null)
            {
                await foreach (var batch in resourceLoggerService.WatchAsync(mongoResourceId).WithCancellation(cancellationToken))
                {
                    foreach (var logLine in batch)
                    {
                        logger.LogInformation("{MongoOutput}", logLine);
                    }
                }
            }
        }
        catch (OperationCanceledException)
        {
            // Expected when the application is shutting down.
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "An exception occurred while getting the logs for the mongo resource");
        }
    }

    public async ValueTask DisposeAsync()
    {
        await this._shutdownCts.CancelAsync();

        if (this._getMongoResourceLogsTask is not null)
        {
            try
            {
                await this._getMongoResourceLogsTask;
            }
            catch (OperationCanceledException)
            {
            }
        }

        this._shutdownCts.Dispose();
    }
}

It is interesting to note that the logs of the .NET Aspire dashboard, which is actually just an executable orchestrated by .NET Aspire, are forwarded to the console using this exact same technique.

Of course, this article is written solely for the purposes of demonstrating the simplified use of IDistributedApplicationLifecycleHook, ResourceNotificationService, and ResourceLoggerService. These types, once combined, offer numerous extensibility possibilities.

Licensed under CC BY 4.0
Ko-fi donations Buy me a coffee