AWS Developer Tools Blog

Building and Debugging .NET Lambda applications with .NET Aspire (Part 2)

In Part 1 of our blog posts for .NET Aspire and AWS Lambda, we showed you how .NET Aspire can be used for running and debugging .NET Lambda functions. In this part, Part 2, we’ll show you how to take advantage of the .NET Aspire programming model for best practices and for connecting dependent resources like cache layers and datastores. We will also show you how to use OpenTelemetry to get telemetry data out of an application, which can be viewed in the .NET Aspire dashboard.

Starting Code

The following code is what a .NET developer might write in order to look up an account from a datastore using caching. In this case, we get an account ID from a resource path, look for the account in a Redis cache, and, if the account isn’t found, fall back to the datastore, which in this case is Amazon DynamoDB. When the code fetches the item from the datastore, it puts the account into the Redis cache for future fetches.


var ddbContext = _host.Services.GetRequiredService<DynamoDBContext>();
var redis = _host.Services.GetRequiredService<IConnectionMultiplexer>().GetDatabase();

var id = request.PathParameters["id"];
context.Logger.LogInformation("Attempting to load account {id}", id);

Accounts account;
var accountJson = redis.StringGet(id);

if (!string.IsNullOrEmpty(accountJson))
{
    context.Logger.LogDebug("Loaded account from redis cache");
    account = JsonSerializer.Deserialize<Accounts>(accountJson);
}
else
{
    context.Logger.LogDebug("Loaded account from DynamoDB");
    account = await ddbContext.LoadAsync<Accounts>(id);
    accountJson = JsonSerializer.Serialize(account);

    if (redis.StringSet(id, JsonSerializer.Serialize(account)))
    {
        context.Logger.LogDebug("Saved account {id} to redis cache", id);
    }
}

var response = new APIGatewayHttpApiV2ProxyResponse
{
    StatusCode = 200,
    Headers = new Dictionary<string, string>
    {
        {"Content-Type", "application/json" }
    },
    Body = accountJson
};

return response;

C#

The logic is simplified for demonstration purposes, but it shows the challenges of working with outside dependencies when running and debugging .NET Lambda functions. When we deploy the function to Lambda, the function would use the real DynamoDB service and provision a Redis cluster with Amazon ElastiCache. When running in a dev environment, how do we connect those dependencies? We don’t want our code to change depending upon whether it is running locally or deployed. This is where the programming model of .NET Aspire can transform how we build .NET Lambda functions.

Setting up OpenTelemetry

Using OpenTelemetry in your .NET projects, including .NET Lambda projects, gives you great insight into what your application is doing across it’s components. .NET Aspire aims to simplify the experience of enabling OpenTelemetry for .NET applications.

The common pattern for .NET Aspire applications is to have a service defaults project. This project has a collection of extension methods that you can use to set common settings and best practices, including enabling OpenTelemetry across your .NET project in the .NET Aspire application. To add a service defaults project to your solution choose “Add -> New Project …” and select the .NET Aspire Service Defaults project template. Common convention is to name the service defaults project <solution-name>.ServiceDefaults.

To enable OpenTelemetry tracing for our AWS and Redis components, add the following packages to the service defaults project.

Next we need to enable these packages in the ConfigureOpenTelemetry method found in the Extensions.cs file.


public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
    builder.Logging.AddOpenTelemetry(logging =>
    {
        logging.IncludeFormattedMessage = true;
        logging.IncludeScopes = true;
    });

    builder.Services.AddOpenTelemetry()
        .WithMetrics(metrics =>
        {
            metrics.AddAspNetCoreInstrumentation()
                .AddHttpClientInstrumentation()
                // Add AWS metrics
                .AddAWSInstrumentation()
                .AddRuntimeInstrumentation();
        })
        .WithTracing(tracing =>
        {
            tracing.AddSource(builder.Environment.ApplicationName)
                .AddAspNetCoreInstrumentation()
                // Add Redis instrumentation
                .AddRedisInstrumentation()
                // Add AWS traces and Lambda configuration
                .AddAWSInstrumentation()
                .AddAWSLambdaConfigurations(options => options.DisableAwsXRayContextExtraction = true)
                .AddHttpClientInstrumentation();
        });

    builder.AddOpenTelemetryExporters();

    return builder;
}
C#

In order for a .NET Lambda function to use the service defaults extension methods, it needs a project reference on the service defaults project, and it needs to configure the services it uses with an IHostApplicationBuilder builder. In the constructor of the following code, the HostApplicationBuilder is used to construct the dependency injection container, and the AddServiceDefaults extension method is called to add our common settings, including the OpenTelemetry configuration we made.


public class Functions
{
    IHost _host;
    TracerProvider _traceProvider;

    public Functions()
    {
        var builder = new HostApplicationBuilder();

        // Call the AddServiceDefaults method from the shared service defaults project.
        builder.AddServiceDefaults();
        
        builder.AddRedisClient(connectionName: "cache");
        builder.Services.AddAWSService<IAmazonDynamoDB>();
        builder.Services.AddSingleton<DynamoDBContext>(sp =>
        {
            return new DynamoDBContext(sp.GetRequiredService<IAmazonDynamoDB>(), new DynamoDBContextConfig
            {
                DisableFetchingTableMetadata = true
            });
        });
        
        _host = builder.Build();
        _traceProvider = _host.Services.GetRequiredService<TracerProvider>();
    }
    
    ...
C#

All of the network operations being done from the Lambda function, such as reaching out to DynamoDB and Redis, should be wrapped in a parent OpenTelemetry trace. This allows us to see, for a Lambda invocation, where the time is being spent and where the potentially faulty areas are. To create a trace for the Lambda invocation, use the AWSLambdaWrapper.TraceAsync method from the OpenTelemetry.Instrumentation.AWSLambda NuGet package.


public Task<APIGatewayHttpApiV2ProxyResponse> GetAccountAsync(APIGatewayHttpApiV2ProxyRequest request, ILambdaContext context)
        => AWSLambdaWrapper.TraceAsync(_traceProvider, async (request, context) =>
        {
            var ddbContext = _host.Services.GetRequiredService<DynamoDBContext>();
            var redis = _host.Services.GetRequiredService<IConnectionMultiplexer>().GetDatabase();

            var id = request.PathParameters["id"];
            context.Logger.LogInformation("Attempting to load account {id}", id);

            Accounts account;
            var accountJson = redis.StringGet(id);

            if (!string.IsNullOrEmpty(accountJson))
            {
                context.Logger.LogDebug("Loaded account from redis cache");
                account = JsonSerializer.Deserialize<Accounts>(accountJson);
            }
            else
            {
                context.Logger.LogDebug("Loaded account from DynamoDB");
                account = await ddbContext.LoadAsync<Accounts>(id);
                accountJson = JsonSerializer.Serialize(account);

                if (redis.StringSet(id, JsonSerializer.Serialize(account)))
                {
                    context.Logger.LogDebug("Saved account {id} to redis cache", id);
                }
            }

            var response = new APIGatewayHttpApiV2ProxyResponse
            {
                StatusCode = 200,
                Headers = new Dictionary<string, string>
                {
                    {"Content-Type", "application/json" }
                },
                Body = accountJson
            };

            return response;
        }, request, context);
            
[DynamoDBTable("Accounts")]
public class Accounts
{
    [DynamoDBHashKey("Id")]
    public string Id { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }
}     
C#

The changes made to the Lambda function for OpenTelemetry are the same changes needed when running in the real Lambda service or in .NET Aspire. Nothing about the code in the Lambda function ties it to .NET Aspire or the local experience.

Setting up the developer inner loop

In the previous blog post, we showed you how to use the .NET Aspire app host to orchestrate the application running locally. For our Lambda function we need to orchestrate access to a Redis instance and DynamoDB with an Accounts table. For DynamoDB we could choose to either provision an Accounts table in DynamoDB or use the DynamoDB local. For this walkthrough we’ll use DynamoDB local.

To get started, add the following packages to the .NET Aspire app host:

In Program.cs, add the Redis and DynamoDB resources. These extension methods take care of starting the underlying container images for the .NET Aspire instance.


var builder = DistributedApplication.CreateBuilder(args);

var cache = builder.AddRedis("cache");
var dynamoDbLocal = builder.AddAWSDynamoDBLocal("DynamoDBAccounts");

C#

At this point we have an instance of DynamoDB local running, but it has no tables or data. Use the following code to create the Accounts table and seed an account in the table. The code within the Subscribe method is invoked once the DynamoDB local container has signified that it is in a ready state.


// Seed the DynamoDB local instance once the resource is ready.
builder.Eventing.Subscribe<ResourceReadyEvent>(dynamoDbLocal.Resource, async (evnt, ct) =>
{
    // Configure DynamoDB service client to connect to DynamoDB local.
    var serviceUrl = dynamoDbLocal.Resource.GetEndpoint("http").Url;
    var ddbClient = new AmazonDynamoDBClient(new AmazonDynamoDBConfig { ServiceURL = serviceUrl });

    // Create the Accounts table.
    await ddbClient.CreateTableAsync(new CreateTableRequest
    {
        TableName = "Accounts",
        AttributeDefinitions = new List<AttributeDefinition>
        {
            new AttributeDefinition { AttributeName = "Id", AttributeType = "S" }
        },
        KeySchema = new List<KeySchemaElement>
        {
            new KeySchemaElement { AttributeName = "Id", KeyType = "HASH" }
        },
        BillingMode = BillingMode.PAY_PER_REQUEST
    });

    // Add an account to the Accounts table.
    await ddbClient.PutItemAsync(new PutItemRequest
    {
        TableName = "Accounts",
        Item = new Dictionary<string, AttributeValue>
        {
            { "Id", new AttributeValue("1") },
            { "Name", new AttributeValue("Amazon") },
            { "Address", new AttributeValue("Seattle, WA") }
        }
    });
});
C#

Running the .NET Aspire app host, we can see that we have successfully added Redis and DynamoDB local to our .NET Aspire application. The next task we need to do is to configure the Lambda function to use these resources.

In the previous blog post we talked about using the AddAWSLambdaFunction and AddAWSAPIGatewayEmulator extension methods to add a .NET Lambda function as a resource to the .NET Aspire application. Recall that these APIs are marked as preview, so to use them, you need to include the #pragma shown in the code below.

This code uses these methods to add the Lambda function and configure access through an Amazon API Gateway endpoint. To connect the function to our Redis and DynamoDB resources, the WithReference method is used. The WithReference method for the DynamoDB local resource overrides the endpoint that the DynamoDB service client created in the Lambda function. The WithReference method for the Redis resource adds the connection string to the Lambda function. The builder.AddRedisClient(connectionName: "cache") line in the Lambda function, shown earlier in this blog post, finds the connection string and uses it.


#pragma warning disable CA2252 // Opt-in for preview features.

var getAccountFunction = builder.AddAWSLambdaFunction<Projects.AccountManagement>(
                                    name: "GetAccountFunction",
                                    lambdaHandler: "AccountManagement::AccountManagement.Functions::GetAccountAsync",
                                    options: new LambdaFunctionOptions
                                    {
                                        ApplicationLogLevel = ApplicationLogLevel.DEBUG
                                    })
                                .WithReference(dynamoDbLocal)
                                .WaitFor(dynamoDbLocal)
                                .WithReference(cache)
                                .WaitFor(cache);

builder.AddAWSAPIGatewayEmulator("APIGatewayEmulator", APIGatewayType.HttpV2)
        .WithReference(getAccountFunction, Method.Get, "/account/{id}");
		
C#

At this point, the developer inner loop is set up so that when we launch the app host, the Redis container, DynamoDB local, and the .NET Lambda function are all launched and connected. For reference, here is the full code of the app host.


using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.Model;
using Amazon.Lambda;
using Aspire.Hosting.AWS.Lambda;

var builder = DistributedApplication.CreateBuilder(args);

var cache = builder.AddRedis("cache");
var dynamoDbLocal = builder.AddAWSDynamoDBLocal("DynamoDBAccounts");

// Seed the DynamoDB local instance once the resource is ready.
builder.Eventing.Subscribe<ResourceReadyEvent>(dynamoDbLocal.Resource, async (evnt, ct) =>
{
    // Configure DynamoDB service client to connect to DynamoDB local.
    var serviceUrl = dynamoDbLocal.Resource.GetEndpoint("http").Url;
    var ddbClient = new AmazonDynamoDBClient(new AmazonDynamoDBConfig { ServiceURL = serviceUrl });

    // Create the Accounts table.
    await ddbClient.CreateTableAsync(new CreateTableRequest
    {
        TableName = "Accounts",
        AttributeDefinitions = new List<AttributeDefinition>
        {
            new AttributeDefinition { AttributeName = "Id", AttributeType = "S" }
        },
        KeySchema = new List<KeySchemaElement>
        {
            new KeySchemaElement { AttributeName = "Id", KeyType = "HASH" }
        },
        BillingMode = BillingMode.PAY_PER_REQUEST
    });

    // Add an account to the Accounts table.
    await ddbClient.PutItemAsync(new PutItemRequest
    {
        TableName = "Accounts",
        Item = new Dictionary<string, AttributeValue>
        {
            { "Id", new AttributeValue("1") },
            { "Name", new AttributeValue("Amazon") },
            { "Address", new AttributeValue("Seattle, WA") }
        }
    });
});

#pragma warning disable CA2252 // Opt-in for preview features.

var getAccountFunction = builder.AddAWSLambdaFunction<Projects.AccountManagement>(
                                    name: "GetAccountFunction",
                                    lambdaHandler: "AccountManagement::AccountManagement.Functions::GetAccountAsync",
                                    options: new LambdaFunctionOptions
                                    {
                                        ApplicationLogLevel = ApplicationLogLevel.DEBUG
                                    })
                                .WithReference(dynamoDbLocal)
                                .WaitFor(dynamoDbLocal)
                                .WithReference(cache)
                                .WaitFor(cache);

builder.AddAWSAPIGatewayEmulator("APIGatewayEmulator", APIGatewayType.HttpV2)
        .WithReference(getAccountFunction, Method.Get, "/account/{id}");

builder.Build().Run();
C#

In Action

Now that the .NET Aspire app host is set up, let’s see it in action. In Visual Studio or Visual Studio Code with the C# Dev Kit installed, launch the app host project. In the .NET Aspire dashboard, we see all of our resources running. By looking at the details of the Lambda function, you can see the environment variables that were set to connect the Redis container and the DynamoDB local instance.

When we navigate to the GET account REST endpoint through the API Gateway emulator, http://localhost:<apigateway-port>/account/1, we can see the data that was seeded in DynamoDB local. If breakpoints were set in the Lambda function when we navigated they would be honored.

Back in the .NET Aspire dashboard, navigate to the “Traces” section and click on the “GetAccountFunction: GetAccountFunction” trace. The trace shows all the activity that was collected by the OpenTelemetry setup completed earlier.

If we navigate to the GET account endpoint again, we see a new trace where DynamoDB local was skipped because the data was cached in Redis.

Automating testing

.NET Aspire can also be used for end-to-end integration tests using the Aspire.Hosting.Testing NuGet package. This means that we can run the .NET Aspire app host within a test, including all of the resources like Redis and DynamoDB local as part of the test. The resources can be retrieved from the app host and inspected.

The following test shows how you can launch the app host and create an HttpClient configured to the API Gateway emulator to make HTTP requests through the emulator and invoke the Lambda functions.


[Fact]
public async Task GetAccountThroughApiGateway()
{
    var appHost = await DistributedApplicationTestingBuilder.CreateAsync<Projects.AccountManagement_AppHost>();
    await using var app = await appHost.BuildAsync();
    await app.StartAsync();

    var resourceNotificationService = app.Services.GetRequiredService<ResourceNotificationService>();
    await resourceNotificationService
        .WaitForResourceAsync("GetAccountFunction", KnownResourceStates.Running)
        .WaitAsync(TimeSpan.FromSeconds(120));

    using var client = app.CreateHttpClient("APIGatewayEmulator");

    var json = await client.GetStringAsync("/account/1");
    Assert.NotNull(json);
}
C#

You can also use the AWS SDK for .NET to invoke Lambda functions directly through the Lambda emulator. This is useful for Lambda functions that are not invoked through API Gateway. The following test shows how you can discover the endpoint of the Lambda emulator and then configure the Lambda service client to invoke the Lambda function.


[Fact]
public async Task GetAccountThroughLambdaSdk()
{
    var appHost = await DistributedApplicationTestingBuilder.CreateAsync<Projects.AccountManagement_AppHost>();
    await using var app = await appHost.BuildAsync();
    await app.StartAsync();

    var resourceNotificationService = app.Services.GetRequiredService<ResourceNotificationService>);
    await resourceNotificationService
        .WaitForResourceAsync("GetAccountFunction", KnownResourceStates.Running)
        .WaitAsync(TimeSpan.FromSeconds(120));

    using var lambdaEndpointClient = app.CreateHttpClient("LambdaServiceEmulator");

    var lambdaConfig = new AmazonLambdaConfig
    {
        ServiceURL = lambdaEndpointClient.BaseAddress!.ToString()
    };
    var lambdaClient = new AmazonLambdaClient(lambdaConfig);

    var invokeRequest = new InvokeRequest
    {
        FunctionName = "GetAccountFunction",
        Payload = CreateGetRequest("1")
    };

    var invokeResponse = await lambdaClient.InvokeAsync(invokeRequest);

    var apiGatewayResponse = JsonSerializer.Deserialize<APIGatewayHttpApiV2ProxyResponse>(invokeResponse.Payload);
    Assert.NotNull(apiGatewayResponse);
    Assert.Equal(200, apiGatewayResponse.StatusCode);
    Assert.NotNull(apiGatewayResponse.Body);
}
C#

Note, when configuring the ServiceURL for a Lambda service client to point to the Lambda emulator, the only operation in the Lambda service client that is supported is the Invoke method.

Conclusion

The combination of .NET Aspire and .NET Lambda functions allows for a simplified orchestration of the resources that are needed for a local developer inner loop. The app host sets up the connections so that .NET developers don’t need to leave their IDE to continually iterate over the code. The end-to-end experience can be tested manually through the .NET Aspire dashboard or automated through end-to-end integration tests.

The development of this feature is happening in our aws/integrations-on-dotnet-aspire-for-aws repository. The following GitHub issue is being used as the main tracker issue for the Lambda integration: https://github.com/aws/integrations-on-dotnet-aspire-for-aws/issues/17. We ask that .NET developers who are building Lambda functions try out this preview and let us know on our repository your success stories and any issues with our Lambda integration.

Norm Johanson

Norm Johanson

Norm Johanson has been a software developer for more than 25 years developing all types of applications. Since 2010 he has been working for AWS focusing on the .NET developer experience at AWS. You can find him on Twitter @socketnorm and GitHub @normj.

.NET