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.
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.
- OpenTelemetry.Instrumentation.AWS – For instrumenting the AWS SDK for .NET.
- OpenTelemetry.Instrumentation.AWSLambda – For instrumenting .NET Lambda functions, including creating the overarching trace for the Lambda invocation.
- OpenTelemetry.Instrumentation.StackExchangeRedis – For instrumenting the access to Redis. Note that at the time of this writing, this package is currently marked as preview with version
1.11.0-beta.1
.
Next we need to enable these packages in the ConfigureOpenTelemetry
method found in the Extensions.cs
file.
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.
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.
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.
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.
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.
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.
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.
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.
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.
.NET