
.NET Aspirations - Embracing OpenTelemetry
In my previous .NET Aspire blog post, we talked about using .NET Aspire to orchestrate the different parts of our web application. However, the .NET Aspire dashboard did not show any telemetry (traces, metrics, or structured logging) because we did not instrument the API or front-end code. We are going to change that in this article, and see how we can easily integrate OpenTelemetry into our application.
Observability with the .NET Aspire Dashboard
When your application is deployed in a production environment, you are likely using observability platforms like Jaeger, Prometheus, New Relic, Honeycomb, Datadog, Azure Monitor, or other vendors that support OpenTelemetry. But you probably don’t have any of these tools in your local development environment. The .NET Aspire dashboard is designed to provide built-in support for observability when debugging your application locally. This allows you to better understand how your application behaves in your development environment, detect potential performance issues, and investigate any problems.
To achieve that, the .NET Aspire dashboard implements an OpenTelemetry Protocol (OTLP) server. It provides OTLP endpoints for applications to send telemetry data, and includes pages to visualize the received data.
That's great, but if we want to see telemetry from our web application, we first need to set up the instrumentation. To demonstrate this, we will use our existing web application, which consists of a Nuxt.js front end called WebApp
and an ASP.NET Core API called WebApi
, and instrument both parts.
Add the instrumentation code to the WebApi
The OpenTelemetry SDK for .NET includes everything we need to collect the telemetry data (logs, traces, metrics). So we could directly install the nuget packages in the WebApi
project, and implement the code to configure the instrumentation and use the the .NET Aspire dashboard OTLP endpoint.It would work, and if you already have an app using OpenTelemetry, you likely only need to make a few configuration changes to make it compatible with the .NET Aspire dashboard.
In our case, we will use instead a .NET Aspire service defaults project to set everything up easily. The purpose of using this Aspire Shared project is to offer predefined extension methods that help configure things like OpenTelemetry, health check endpoints, retry policies, and more across the various services and resources that make up the application. Let’s create this project using the aspire-servicedefaults
template (from the .NET Aspire template) and reference it in the WebApi
project.
dotnet new aspire-servicedefaults -o ServiceDefaults
dotnet sln AspnetWithNuxt.slnx add ServiceDefaults\ServiceDefaults.csproj
dotnet add .\WebApi\WebApi.csproj reference .\ServiceDefaults\ServiceDefaults.csproj
We can see the ServiceDefaults
project contains references to the necessary OpenTelemetry nuget packages and other packages for resilience and service discovery:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.0.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.9.0" />
</ItemGroup>
</Project>
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()
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation()
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
//.AddGrpcClientInstrumentation()
.AddHttpClientInstrumentation();
});
builder.AddOpenTelemetryExporters();
return builder;
}
If we want to customize something in this instrumentation code, we can directly modify this code. Here, we will let everything like that and just call this method in our WebApi
project.
var builder = WebApplication.CreateBuilder(args);
builder.ConfigureOpenTelemetry();
builder.AddServiceDefaults
, which configures everything directly.Since .NET Aspire automatically provides the needed OpenTelemetry environment variables to the various resources it manages, the code we added in the WebApi
will correctly export the traces, metrics, and logs to the dashboard, where they will be displayed.
If we had other .NET resources (such as other APIs, services, or a Blazor front end), we could configure them just as easily by calling the same methods from ServiceDefaults
. However, for a browser application, it's not as straightforward.
Add the instrumentation code to the WebApp
There are several challenges when it comes to instrumenting applications running in the browser:
- Browser apps don’t support gRPC, so they can't use the gRPC OTLP endpoint.
- At the time of writing, the OpenTelemetry JavaScript SDK’s documentation states that:
- The client instrumentation for the browser is still experimental and mostly unspecified.
- Only the traces and metrics signals are stable; the logs signal is still in development.
- OTEL environment variables need to be made available to the browser app
The first point can be handled easily by replacing the environment variable DOTNET_DASHBOARD_OTLP_ENDPOINT_URL
by the environment variable DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL
in the launchsettings.json
file the AppHost
. It will enable the OTLP HTTP endpoint.
"DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "https://localhost:16175",
For the second point, there isn't much we can do. However, even though client instrumentation in the browser is still experimental, the OpenTelemetry JavaScript SDK works well and there are resources to help you set it up:
- Examples in the OpenTelemetry documentation
- This tutorial in the Microsoft documentation
- This great video from Aaron Powell
- Some code samples on GitHub like this one or this one
Anyway, for the moment we won’t be able to see in the dashboard structured logs coming from the WebApp
. However, what matters more to me than logs or even metrics are traces and the ability to correlate traces from both the WebApp
and the WebApi
. Without that, it’s hard to link events happening in the frontend with those in the backend.
I won’t explain everything we need to do to add the instrumentation code to our Nuxt.js application WebApp
because I already did it in this blog post. What I did is I implemented an OpenTelemetry plugin for Nuxt.js that export the application traces to an OTEL backend. You can grab the code here but I suggest you to read the article to understand how this code is using the OpenTelemetry SDKs.
Starting from this code, we now just have to make it work with the .NET Aspire dashboard as the OTEL backend. That’s the third, to provide the OTEL environment variables that the browser application will use.
In the WebApp
, we have some OpenTelemetry configuration that the plugin uses to process and export the telemetry data:
runtimeConfig: {
public: {
otelExporterOtlpEndpoint: '',
otelExporterOtlpHeaders: '',
otelResourceAttributes: '',
otelServiceName: '',
}
}
To override this configuration at runtime (you don’t want to set them in your code because you would only override them at build time), we would need to set these environment variables:
NUXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT
NUXT_PUBLIC_OTEL_RESOURCE_ATTRIBUTES
NUXT_PUBLIC_OTEL_EXPORTER_OTLP_HEADERS
NUXT_PUBLIC_OTEL_SERVICE_NAME
Those are the same environment variables that .NET Aspire provides to the WebApp
but prefixed with NUXT_PUBLIC
. Fortunately, someone with the same problem but for Vite created an issue on the Aspire repository and David Fowler suggested the following code to automatically add a prefix:
namespace AppHost;
public static class EnvironmentExtensions
{
public static IResourceBuilder<T> WithEnvironmentPrefix<T>(this IResourceBuilder<T> resourceBuilder, string prefix)
where T : IResourceWithEnvironment
{
return resourceBuilder.WithEnvironment(context =>
{
var kvps = context.EnvironmentVariables.ToArray();
// Adds a prefix to all environment variable names
foreach (var p in kvps)
{
context.EnvironmentVariables[$"{prefix}{p.Key}"] = p.Value;
}
});
}
}
This is useful for OpenTelemetry but could be useful for other environment variables as well so I did not add filters. To make it work we just have to call the method WithEnvironmentPrefix
when defining our WebApp
resource in the AppHost
.
var webApp= builder.AddPnpmApp("WebApp", "../WebApp", "dev")
.WithHttpsEndpoint(env: "PORT")
.WithExternalHttpEndpoints()
.WithPnpmPackageInstallation()
.WithReference(webApi)
.WaitFor(webApi)
.WithEnvironment("ApiUrl", webApi.GetEndpoint("https"))
.WithEnvironmentPrefix("NUXT_PUBLIC_");
We can check that we now have the environment variables twice (with prefix and without) so that .NET Aspire correctly provides the OTEL configuration in the WebApp
.
And with just that, we can now see both the traces from the WebApi
and the WebApp
in the dashboard.
Final thoughts
Thanks to the .NET Aspire dashboard, we can easily view telemetry data from different parts of the application locally. Additionally, with the ServiceDefaults
project, integrating OpenTelemetry with .NET applications is straightforward. Although adding instrumentation to browser applications can be challenging and requires some extra work, it is still possible and improves the overall observability of the application.
Not only does .NET Aspire improve the local development experience by bringing observability to where you code, but it also encourages you to implement things correctly by default. Without .NET Aspire, I probably wouldn't have delved into the subject of OpenTelemetry and kept using vendor specific SDKs.
You can find the complete code here. Keep learning.