Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
506 changes: 252 additions & 254 deletions .editorconfig

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Copilot Instructions

## Package Constraints

- **FluentAssertions**: Do not upgrade beyond major version 7.x due to a licensing change in version 8+.
- **Swashbuckle.AspNetCore**: Do not upgrade beyond version 6.x due to breaking changes in Microsoft.OpenApi v2.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -396,4 +396,3 @@ FodyWeavers.xsd

# JetBrains Rider
*.sln.iml
/sample/DockerOpenTelemetry/output/logs.json
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<DefaultLanguage>en-US</DefaultLanguage>
<SolutionDir Condition=" '$(SolutionDir)' == '' OR '$(SolutionDir)' == '*Undefined if not building a solution or within Visual Studio*' ">$(MSBuildThisFileDirectory)</SolutionDir>
<IsTestProject>$(MSBuildProjectName.EndsWith('.Tests'))</IsTestProject>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>Latest</LangVersion>
Expand Down
46 changes: 22 additions & 24 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -1,33 +1,31 @@
<Project>
<!-- Runtime -->
<ItemGroup>
<PackageVersion Include="Asp.Versioning.Http" Version="8.1.0" />
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageVersion Include="Azure.Core" Version="1.44.0" />
<PackageVersion Include="DotNet.ReproducibleBuilds" Version="1.2.25" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="7.0.3" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="8.0.5" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="8.0.2" />
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.6.143" />
<PackageVersion Include="OpenTelemetry" Version="1.9.0" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.9.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.9.0" />
<PackageVersion Include="Asp.Versioning.Http" Version="10.0.0-preview.1" />
<PackageVersion Include="Asp.Versioning.Mvc" Version="10.0.0-preview.1" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.0.0-preview.1" />
<PackageVersion Include="Azure.Core" Version="1.51.1" />
<PackageVersion Include="DotNet.ReproducibleBuilds" Version="2.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.3" />
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.9.50" />
<PackageVersion Include="OpenTelemetry" Version="1.15.0" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.15.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.8.1" />
</ItemGroup>
<!-- Test -->
<ItemGroup>
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
<PackageVersion Include="FluentAssertions" Version="6.12.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.2" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageVersion Include="xunit" Version="2.8.0" />
<PackageVersion Include="xunit.categories" Version="2.0.6" />
<PackageVersion Include="Xunit.DependencyInjection" Version="8.7.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.0" />
<PackageVersion Include="coverlet.collector" Version="8.0.0" />
<PackageVersion Include="FluentAssertions" Version="7.2.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.3" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ By default, a meter named `ServiceLevelIndicator` with instrument name `operatio
"Ok" when the http response status code is in the 2xx range,
"Error" when the http response status code is in the 5xx range,
"Unset" for any other status code.
- http.response.status_code - The http status code.
- http.response.status.code - The http status code.
- http.request.method (Optional)- The http request method (GET, POST, etc) is added.

Difference between ServiceLevelIndicator and http.server.request.duration
Expand Down Expand Up @@ -304,12 +304,14 @@ An async version `EnrichAsync` is also available.

Try out the sample weather forecast Web API.

To view the metrics locally.
To view the metrics locally using the [.NET Aspire Dashboard](https://aspire.dev/dashboard/standalone/):

1. Run Docker Desktop
2. Run [sample\DockerOpenTelemetry\run.cmd](sample\DockerOpenTelemetry\run.cmd) to download and run zipkin and prometheus.
3. Run the sample web API project and call the `GET WeatherForecast` using the Open API UI.
4. You should see the SLI metrics in prometheus under the meter `operation_duration_milliseconds_bucket` where the `Operation = "GET WeatherForeCase"`, `http.response.status_code = 200`, `LocationId = "ms-loc://az/public/westus2"`, `activity.status_code = Ok`
1. Start the Aspire dashboard:
```
docker run --rm -it -d -p 18888:18888 -p 4317:18889 -e DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true -e DASHBOARD__OTLP__AUTHMODE=Unsecured --name aspire-dashboard mcr.microsoft.com/dotnet/aspire-dashboard:latest
```
2. Run the sample web API project and call the `GET WeatherForecast` using the Open API UI.
3. Open `http://localhost:18888` to view the dashboard. You should see the SLI metrics under the meter `operation_duration_milliseconds_bucket` where the `Operation = "GET WeatherForecast"`, `http.response.status.code = 200`, `LocationId = "ms-loc://az/public/westus2"`, `activity.status.code = Ok`
![SLI](assets/prometheus.jpg)
5. If you run the sample with API Versioning, you will see something similar to the following.
4. If you run the sample with API Versioning, you will see something similar to the following.
![SLI](assets/versioned.jpg)
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
namespace ServiceLevelIndicators;

using System.Threading;
using System.Threading.Tasks;
using Asp.Versioning;
Expand All @@ -15,7 +16,7 @@ public ValueTask EnrichAsync(WebEnrichmentContext context, CancellationToken can

private static string GetApiVersion(HttpContext context)
{
var apiVersioningFeature = context.ApiVersioningFeature();
var apiVersioningFeature = context.ApiVersioningFeature;
var versions = apiVersioningFeature.RawRequestedApiVersions;
if (versions.Count == 1)
return apiVersioningFeature.RequestedApiVersion?.ToString() ?? string.Empty;
Expand All @@ -26,4 +27,4 @@ private static string GetApiVersion(HttpContext context)

return "Unspecified";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ public static IServiceLevelIndicatorBuilder AddApiVersion(this IServiceLevelIndi
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IEnrichment<WebEnrichmentContext>, ApiVersionEnrichment>());
return builder;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
namespace ServiceLevelIndicators.Asp.ApiVersioning.Tests;
namespace ServiceLevelIndicators.Asp.ApiVersioning.Tests;

using System.Diagnostics.Metrics;
using System.Net;
using global::Asp.Versioning;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Diagnostics.Metrics;
using System.Net;
using Xunit.Abstractions;

public class ServiceLevelIndicatorVersionedAspTests : IDisposable
{
Expand Down Expand Up @@ -57,7 +56,7 @@ public async Task SLI_Metrics_is_emitted_with_API_version_as_query_parameter()
using var host = await CreateHost();

// Act
var response = await host.GetTestClient().GetAsync("testSingle?api-version=2023-08-29");
var response = await host.GetTestClient().GetAsync("testSingle?api-version=2023-08-29", TestContext.Current.CancellationToken);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
Expand All @@ -82,7 +81,7 @@ public async Task SLI_Metrics_is_emitted_with_API_version_as_header()
httpClient.DefaultRequestHeaders.Add("api-version", "2023-08-29");

// Act
var response = await httpClient.GetAsync("testSingle");
var response = await httpClient.GetAsync("testSingle", TestContext.Current.CancellationToken);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
Expand All @@ -105,7 +104,7 @@ public async Task SLI_Metrics_is_emitted_with_neutral_API_version()
using var host = await CreateHost();

// Act
var response = await host.GetTestClient().GetAsync("testNeutral");
var response = await host.GetTestClient().GetAsync("testNeutral", TestContext.Current.CancellationToken);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
Expand All @@ -128,7 +127,7 @@ public async Task SLI_Metrics_is_emitted_with_default_API_version()
using var host = await CreateHostWithDefaultApiVersion();

// Act
var response = await host.GetTestClient().GetAsync("testSingle");
var response = await host.GetTestClient().GetAsync("testSingle", TestContext.Current.CancellationToken);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
Expand All @@ -142,7 +141,7 @@ public async Task Middleware_should_not_emit_metrics_for_nonexistent_route()
using var host = await CreateHost();

// Act
var response = await host.GetTestClient().GetAsync("does-not-exist?api-version=2023-08-29");
var response = await host.GetTestClient().GetAsync("does-not-exist?api-version=2023-08-29", TestContext.Current.CancellationToken);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
Expand All @@ -167,7 +166,7 @@ public async Task SLI_Metrics_is_emitted_when_api_version_is_invalid(string rout
using var host = await CreateHost();

// Act
var response = await host.GetTestClient().GetAsync(routeWithVersion);
var response = await host.GetTestClient().GetAsync(routeWithVersion, TestContext.Current.CancellationToken);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
Expand All @@ -176,9 +175,7 @@ public async Task SLI_Metrics_is_emitted_when_api_version_is_invalid(string rout

private async Task<IHost> CreateHost() =>
await new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder
.ConfigureWebHost(webBuilder => webBuilder
.UseTestServer()
.ConfigureServices(services =>
{
Expand All @@ -197,25 +194,19 @@ private async Task<IHost> CreateHost() =>
.AddMvc()
.AddApiVersion();
})
.Configure(app =>
{
app.UseRouting()
.Configure(app => app.UseRouting()
.UseServiceLevelIndicator()
.Use(async (context, next) =>
{
await Task.Delay(MillisecondsDelay);
await next(context);
})
.UseEndpoints(endpoints => endpoints.MapControllers());
});
})
.UseEndpoints(endpoints => endpoints.MapControllers())))
.StartAsync();

private async Task<IHost> CreateHostWithDefaultApiVersion() =>
await new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder
.ConfigureWebHost(webBuilder => webBuilder
.UseTestServer()
.ConfigureServices(services =>
{
Expand All @@ -236,18 +227,14 @@ private async Task<IHost> CreateHostWithDefaultApiVersion() =>
.AddMvc()
.AddApiVersion();
})
.Configure(app =>
{
app.UseRouting()
.Configure(app => app.UseRouting()
.UseServiceLevelIndicator()
.Use(async (context, next) =>
{
await Task.Delay(MillisecondsDelay);
await next(context);
})
.UseEndpoints(endpoints => endpoints.MapControllers());
});
})
.UseEndpoints(endpoints => endpoints.MapControllers())))
.StartAsync();

private void ValidateMetrics()
Expand Down Expand Up @@ -286,4 +273,4 @@ public void Dispose()
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace ServiceLevelIndicators.Asp.ApiVersioning.Tests;

using Microsoft.AspNetCore.Mvc;
using global::Asp.Versioning;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("[controller]")]
Expand All @@ -11,4 +11,4 @@ public class TestDoubleController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok("Hello World!");
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace ServiceLevelIndicators.Asp.ApiVersioning.Tests;

using Microsoft.AspNetCore.Mvc;
using global::Asp.Versioning;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("[controller]")]
Expand All @@ -10,4 +10,4 @@ public class TestNeutralController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok("Hello World!");
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace ServiceLevelIndicators.Asp.ApiVersioning.Tests;

using Microsoft.AspNetCore.Mvc;
using global::Asp.Versioning;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("[controller]")]
Expand All @@ -10,4 +10,4 @@ public class TestSingleController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok("Hello World!");
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
namespace ServiceLevelIndicators;

/// <summary>
/// Marks a route parameter as the customer resource identifier for SLI metrics.
/// Only one parameter per endpoint may have this attribute.
/// </summary>
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
public sealed class CustomerResourceIdAttribute : Attribute
{
}
}
10 changes: 8 additions & 2 deletions ServiceLevelIndicators.Asp/src/CustomerResourceIdMetadata.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
namespace ServiceLevelIndicators;

public class CustomerResourceIdMetadata(string routeValueName)
/// <summary>
/// Endpoint metadata indicating which route value supplies the customer resource identifier.
/// </summary>
public sealed class CustomerResourceIdMetadata(string routeValueName)
{
/// <summary>
/// Gets the route value name mapped to the customer resource identifier.
/// </summary>
public string RouteValueName { get; } = routeValueName;
}
}
15 changes: 14 additions & 1 deletion ServiceLevelIndicators.Asp/src/EndpointBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,19 @@
using System.Reflection;
using Microsoft.AspNetCore.Builder;

/// <summary>
/// Extension methods for adding SLI metadata to Minimal API endpoints.
/// </summary>
public static class EndpointBuilderExtensions
{
/// <summary>
/// Marks a Minimal API endpoint for SLI metric emission and scans handler parameters
/// for <see cref="CustomerResourceIdAttribute"/> and <see cref="MeasureAttribute"/>.
/// </summary>
/// <typeparam name="TBuilder">The endpoint convention builder type.</typeparam>
/// <param name="builder">The endpoint builder.</param>
/// <param name="operation">An optional custom operation name; if omitted, the route template is used.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
public static TBuilder AddServiceLevelIndicator<TBuilder>(this TBuilder builder, string? operation = default)
where TBuilder : notnull, IEndpointConventionBuilder
{
Expand Down Expand Up @@ -32,6 +43,8 @@ private static void AddSliMetadata(EndpointBuilder endpoint)
switch (attributes[j])
{
case CustomerResourceIdAttribute:
if (endpoint.Metadata.OfType<CustomerResourceIdMetadata>().Any())
throw new InvalidOperationException("Multiple " + nameof(CustomerResourceIdAttribute) + " defined on endpoint '" + endpoint.DisplayName + "'.");
endpoint.Metadata.Add(new CustomerResourceIdMetadata(parameter.Name!));
break;
case MeasureAttribute measure:
Expand All @@ -41,4 +54,4 @@ private static void AddSliMetadata(EndpointBuilder endpoint)
}
}
}
}
}
Loading