From 1690fefb6974bb08e22ee689b3a79ef23f1c0cb5 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Fri, 21 Feb 2025 19:11:30 +0000 Subject: [PATCH 01/29] feat(metrics): add ASP.NET Core metrics package with cold start tracking and middleware support for aspnetcore. Docs --- docs/core/metrics-v2.md | 884 ++++++++++++++++++ docs/core/metrics.md | 2 +- libraries/AWS.Lambda.Powertools.sln | 30 + ...ambda.Powertools.Metrics.AspNetCore.csproj | 23 + .../Http/MetricsEndpointExtensions.cs | 37 + .../Http/MetricsFilter.cs | 57 ++ .../Http/MetricsHelper.cs | 76 ++ .../Http/MetricsMiddlewareExtensions.cs | 41 + .../InternalsVisibleTo.cs | 18 + .../README.md | 149 +++ libraries/src/Directory.Packages.props | 2 + ...Powertools.Metrics.AspNetCore.Tests.csproj | 32 + .../MetricsFilterTests.cs | 110 +++ .../MetricsHelperTests.cs | 84 ++ .../MetricsMiddlewareExtensionsTests.cs | 99 ++ .../Handlers/DefaultDimensionsHandler.cs | 7 +- .../Handlers/FunctionHandlerTests.cs | 8 +- mkdocs.yml | 1 + 18 files changed, 1653 insertions(+), 7 deletions(-) create mode 100644 docs/core/metrics-v2.md create mode 100644 libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/AWS.Lambda.Powertools.Metrics.AspNetCore.csproj create mode 100644 libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsEndpointExtensions.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsFilter.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsHelper.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsMiddlewareExtensions.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/InternalsVisibleTo.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/README.md create mode 100644 libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj create mode 100644 libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsFilterTests.cs create mode 100644 libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsHelperTests.cs create mode 100644 libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsMiddlewareExtensionsTests.cs diff --git a/docs/core/metrics-v2.md b/docs/core/metrics-v2.md new file mode 100644 index 00000000..af5f6859 --- /dev/null +++ b/docs/core/metrics-v2.md @@ -0,0 +1,884 @@ +--- +title: Metrics V2 +description: Core utility +--- + +Metrics creates custom metrics asynchronously by logging metrics to standard output following [Amazon CloudWatch Embedded Metric Format (EMF)](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format.html). + +These metrics can be visualized through [Amazon CloudWatch Console](https://aws.amazon.com/cloudwatch/). + +## Key features + +* Aggregate up to 100 metrics using a single [CloudWatch EMF](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html){target="_blank"} object (large JSON blob) +* Validating your metrics against common metric definitions mistakes (for example, metric unit, values, max dimensions, max metrics) +* Metrics are created asynchronously by the CloudWatch service. You do not need any custom stacks, and there is no impact to Lambda function latency +* Context manager to create a one off metric with a different dimension +* Ahead-of-Time compilation to native code support [AOT](https://docs.aws.amazon.com/lambda/latest/dg/dotnet-native-aot.html) from version 1.7.0 +* Support for AspNetCore middleware and filters to capture metrics for HTTP requests + +
+ +
+ Screenshot of the Amazon CloudWatch Console showing an example of business metrics in the Metrics Explorer +
Metrics showcase - Metrics Explorer
+
+ +## Installation + +Powertools for AWS Lambda (.NET) are available as NuGet packages. You can install the packages from [NuGet Gallery](https://www.nuget.org/packages?q=AWS+Lambda+Powertools*){target="_blank"} or from Visual Studio editor by searching `AWS.Lambda.Powertools*` to see various utilities available. + +* [AWS.Lambda.Powertools.Metrics](https://www.nuget.org/packages?q=AWS.Lambda.Powertools.Metrics): + + `dotnet nuget add AWS.Lambda.Powertools.Metrics` + +## Terminologies + +If you're new to Amazon CloudWatch, there are two terminologies you must be aware of before using this utility: + +* **Namespace**. It's the highest level container that will group multiple metrics from multiple services for a given application, for example `ServerlessEcommerce`. +* **Dimensions**. Metrics metadata in key-value format. They help you slice and dice metrics visualization, for example `ColdStart` metric by Payment `service`. +* **Metric**. It's the name of the metric, for example: SuccessfulBooking or UpdatedBooking. +* **Unit**. It's a value representing the unit of measure for the corresponding metric, for example: Count or Seconds. +* **Resolution**. It's a value representing the storage resolution for the corresponding metric. Metrics can be either Standard or High resolution. Read more [here](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_concepts.html#Resolution_definition). + +Visit the AWS documentation for a complete explanation for [Amazon CloudWatch concepts](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_concepts.html). + +
+ +
Metric terminology, visually explained
+
+ +## Getting started + +**`Metrics`** is implemented as a Singleton to keep track of your aggregate metrics in memory and make them accessible anywhere in your code. To guarantee that metrics are flushed properly the **`MetricsAttribute`** must be added on the lambda handler. + +Metrics has two global settings that will be used across all metrics emitted. Use your application or main service as the metric namespace to easily group all metrics: + +Setting | Description | Environment variable | Constructor parameter +------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------- +**Service** | Optionally, sets **service** metric dimension across all metrics e.g. `payment` | `POWERTOOLS_SERVICE_NAME` | `Service` +**Metric namespace** | Logical container where all metrics will be placed e.g. `MyCompanyEcommerce` | `POWERTOOLS_METRICS_NAMESPACE` | `Namespace` + +!!! info "Autocomplete Metric Units" + All parameters in **`Metrics Attribute`** are optional. Following rules apply: + + - **Namespace:** **`Empty`** string by default. You can either specify it in code or environment variable. If not present before flushing metrics, a **`SchemaValidationException`** will be thrown. + - **Service:** **`service_undefined`** by default. You can either specify it in code or environment variable. + - **CaptureColdStart:** **`false`** by default. + - **RaiseOnEmptyMetrics:** **`false`** by default. + +### Full list of environment variables + +| Environment variable | Description | Default | +| ------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------- | +| **POWERTOOLS_SERVICE_NAME** | Sets service name used for tracing namespace, metrics dimension and structured logging | `"service_undefined"` | +| **POWERTOOLS_METRICS_NAMESPACE** | Sets namespace used for metrics | `None` | + +### Metrics object + +#### Attribute + +The **`MetricsAttribute`** is a class-level attribute that can be used to set the namespace and service for all metrics emitted by the lambda handler. + +```csharp hl_lines="3" +using AWS.Lambda.Powertools.Metrics; + +[Metrics(Namespace = "ExampleApplication", Service = "Booking")] +public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) +{ + ... +} +``` + +#### Methods + +The **`Metrics`** class provides methods to add metrics, dimensions, and metadata to the metrics object. + +```csharp hl_lines="5-7" +using AWS.Lambda.Powertools.Metrics; + +public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) +{ + Metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + Metrics.AddDimension("Environment", "Prod"); + Metrics.AddMetadata("BookingId", "683EEB2D-B2F3-4075-96EE-788E6E2EED45"); + ... +} +``` + +#### Initialization + +The **`Metrics`** object is initialized as a Singleton and can be accessed anywhere in your code. + +But can also be initialize with `Configure` or `Builder` patterns in your Lambda constructor, this the best option for testing. + +Configure: + +```csharp +using AWS.Lambda.Powertools.Metrics; + +public Function() +{ + Metrics.Configure(options => + { + options.Namespace = "dotnet-powertools-test"; + options.Service = "testService"; + options.CaptureColdStart = true; + options.DefaultDimensions = new Dictionary + { + { "Environment", "Prod" }, + { "Another", "One" } + }; + }); +} + +[Metrics] +public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) +{ + Metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + ... +} +``` + +Builder: + +```csharp +using AWS.Lambda.Powertools.Metrics; + +private readonly IMetrics _metrics; + +public Function() +{ + _metrics = new MetricsBuilder() + .WithCaptureColdStart(true) + .WithService("testService") + .WithNamespace("dotnet-powertools-test") + .WithDefaultDimensions(new Dictionary + { + { "Environment", "Prod1" }, + { "Another", "One" } + }).Build(); +} + +[Metrics] +public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) +{ + _metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + ... +} +``` + + +### Creating metrics + +You can create metrics using **`AddMetric`**, and you can create dimensions for all your aggregate metrics using **`AddDimension`** method. + +=== "Metrics" + + ```csharp hl_lines="5 8" + using AWS.Lambda.Powertools.Metrics; + + public class Function { + + [Metrics(Namespace = "ExampleApplication", Service = "Booking")] + public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + Metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + } + } + ``` +=== "Metrics with custom dimensions" + + ```csharp hl_lines="8-9" + using AWS.Lambda.Powertools.Metrics; + + public class Function { + + [Metrics(Namespace = "ExampleApplication", Service = "Booking")] + public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + Metrics.AddDimension("Environment","Prod"); + Metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + } + } + ``` + +!!! tip "Autocomplete Metric Units" + `MetricUnit` enum facilitates finding a supported metric unit by CloudWatch. + +!!! note "Metrics overflow" + CloudWatch EMF supports a max of 100 metrics per batch. Metrics utility will flush all metrics when adding the 100th metric. Subsequent metrics, e.g. 101th, will be aggregated into a new EMF object, for your convenience. + +!!! warning "Metric value must be a positive number" + Metric values must be a positive number otherwise an `ArgumentException` will be thrown. + +!!! warning "Do not create metrics or dimensions outside the handler" + Metrics or dimensions added in the global scope will only be added during cold start. Disregard if that's the intended behavior. + +### Adding high-resolution metrics + +You can create [high-resolution metrics](https://aws.amazon.com/about-aws/whats-new/2023/02/amazon-cloudwatch-high-resolution-metric-extraction-structured-logs/) passing `MetricResolution` as parameter to `AddMetric`. + +!!! tip "When is it useful?" + High-resolution metrics are data with a granularity of one second and are very useful in several situations such as telemetry, time series, real-time incident management, and others. + +=== "Metrics with high resolution" + + ```csharp hl_lines="9 12 15" + using AWS.Lambda.Powertools.Metrics; + + public class Function { + + [Metrics(Namespace = "ExampleApplication", Service = "Booking")] + public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + // Publish a metric with standard resolution i.e. StorageResolution = 60 + Metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count, MetricResolution.Standard); + + // Publish a metric with high resolution i.e. StorageResolution = 1 + Metrics.AddMetric("FailedBooking", 1, MetricUnit.Count, MetricResolution.High); + + // The last parameter (storage resolution) is optional + Metrics.AddMetric("SuccessfulUpgrade", 1, MetricUnit.Count); + } + } + ``` + +!!! tip "Autocomplete Metric Resolutions" + Use the `MetricResolution` enum to easily find a supported metric resolution by CloudWatch. + +### Adding default dimensions + +You can use **`SetDefaultDimensions`** method to persist dimensions across Lambda invocations. + +=== "SetDefaultDimensions method" + + ```csharp hl_lines="4 5 6 7 12" + using AWS.Lambda.Powertools.Metrics; + + public class Function { + private Dictionary _defaultDimensions = new Dictionary{ + {"Environment", "Prod"}, + {"Another", "One"} + }; + + [Metrics(Namespace = "ExampleApplication", Service = "Booking")] + public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + Metrics.SetDefaultDimensions(_defaultDimensions); + Metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + } + } + ``` + +### Adding default dimensions with cold start metric + +You can use the Builder or Configure patterns in your Lambda class constructor to set default dimensions. + +=== "Builder pattern" + + ```csharp hl_lines="12-16" + using AWS.Lambda.Powertools.Metrics; + + public class Function { + private readonly IMetrics _metrics; + + public Function() + { + _metrics = new MetricsBuilder() + .WithCaptureColdStart(true) + .WithService("testService") + .WithNamespace("dotnet-powertools-test") + .WithDefaultDimensions(new Dictionary + { + { "Environment", "Prod1" }, + { "Another", "One" } + }).Build(); + } + + [Metrics] + public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + _metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + ... + } + ``` +=== "Configure pattern" + + ```csharp hl_lines="12-16" + using AWS.Lambda.Powertools.Metrics; + + public class Function { + + public Function() + { + Metrics.Configure(options => + { + options.Namespace = "dotnet-powertools-test"; + options.Service = "testService"; + options.CaptureColdStart = true; + options.DefaultDimensions = new Dictionary + { + { "Environment", "Prod" }, + { "Another", "One" } + }; + }); + } + + [Metrics] + public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + Metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + ... + } + ``` +### Adding dimensions + +You can add dimensions to your metrics using **`AddDimension`** method. + +=== "Function.cs" + + ```csharp hl_lines="8" + using AWS.Lambda.Powertools.Metrics; + + public class Function { + + [Metrics(Namespace = "ExampleApplication", Service = "Booking")] + public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + Metrics.AddDimension("Environment","Prod"); + Metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + } + } + ``` +=== "Example CloudWatch Logs excerpt" + + ```json hl_lines="11 24" + { + "SuccessfulBooking": 1.0, + "_aws": { + "Timestamp": 1592234975665, + "CloudWatchMetrics": [ + { + "Namespace": "ExampleApplication", + "Dimensions": [ + [ + "service", + "Environment" + ] + ], + "Metrics": [ + { + "Name": "SuccessfulBooking", + "Unit": "Count" + } + ] + } + ] + }, + "service": "ExampleService", + "Environment": "Prod" + } + ``` + +### Flushing metrics + +With **`MetricsAttribute`** all your metrics are validated, serialized and flushed to standard output when lambda handler completes execution or when you had the 100th metric to memory. + +You can also flush metrics manually by calling **`Flush`** method. + +During metrics validation, if no metrics are provided then a warning will be logged, but no exception will be raised. + +=== "Function.cs" + + ```csharp hl_lines="9" + using AWS.Lambda.Powertools.Metrics; + + public class Function { + + [Metrics(Namespace = "ExampleApplication", Service = "Booking")] + public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + Metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + Metrics.Flush(); + } + } + ``` +=== "Example CloudWatch Logs excerpt" + + ```json hl_lines="2 7 10 15 22" + { + "BookingConfirmation": 1.0, + "_aws": { + "Timestamp": 1592234975665, + "CloudWatchMetrics": [ + { + "Namespace": "ExampleApplication", + "Dimensions": [ + [ + "service" + ] + ], + "Metrics": [ + { + "Name": "BookingConfirmation", + "Unit": "Count" + } + ] + } + ] + }, + "service": "ExampleService" + } + ``` + +!!! tip "Metric validation" + If metrics are provided, and any of the following criteria are not met, **`SchemaValidationException`** will be raised: + + * Maximum of 9 dimensions + * Namespace is set + * Metric units must be [supported by CloudWatch](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_MetricDatum.html) + +!!! info "We do not emit 0 as a value for ColdStart metric for cost reasons. [Let us know](https://github.com/aws-powertools/powertools-lambda-dotnet/issues/new?assignees=&labels=feature-request%2Ctriage&template=feature_request.yml&title=Feature+request%3A+TITLE) if you'd prefer a flag to override it" + +### Raising SchemaValidationException on empty metrics + +If you want to ensure that at least one metric is emitted, you can pass **`RaiseOnEmptyMetrics`** to the Metrics attribute: + +=== "Function.cs" + + ```python hl_lines="5" + using AWS.Lambda.Powertools.Metrics; + + public class Function { + + [Metrics(RaiseOnEmptyMetrics = true)] + public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + ... + ``` + +### Capturing cold start metric + +You can optionally capture cold start metrics by setting **`CaptureColdStart`** parameter to `true`. + +=== "Function.cs" + + ```csharp hl_lines="5" + using AWS.Lambda.Powertools.Metrics; + + public class Function { + + [Metrics(CaptureColdStart = true)] + public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + ... + ``` +=== "Builder pattern" + + ```csharp hl_lines="9" + using AWS.Lambda.Powertools.Metrics; + + public class Function { + private readonly IMetrics _metrics; + + public Function() + { + _metrics = new MetricsBuilder() + .WithCaptureColdStart(true) + .WithService("testService") + .WithNamespace("dotnet-powertools-test") + } + + [Metrics] + public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + _metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + ... + } + ``` +=== "Configure pattern" + + ```csharp hl_lines="11" + using AWS.Lambda.Powertools.Metrics; + + public class Function { + + public Function() + { + Metrics.Configure(options => + { + options.Namespace = "dotnet-powertools-test"; + options.Service = "testService"; + options.CaptureColdStart = true; + }); + } + + [Metrics] + public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + Metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + ... + } + ``` + +If it's a cold start invocation, this feature will: + +* Create a separate EMF blob solely containing a metric named `ColdStart` +* Add `FunctionName` and `Service` dimensions + +This has the advantage of keeping cold start metric separate from your application metrics, where you might have unrelated dimensions. + +## Advanced + +### Adding metadata + +You can add high-cardinality data as part of your Metrics log with `AddMetadata` method. This is useful when you want to search highly contextual information along with your metrics in your logs. + +!!! info + **This will not be available during metrics visualization** - Use **dimensions** for this purpose + +!!! info + Adding metadata with a key that is the same as an existing metric will be ignored + +=== "Function.cs" + + ```csharp hl_lines="9" + using AWS.Lambda.Powertools.Metrics; + + public class Function { + + [Metrics(Namespace = ExampleApplication, Service = "Booking")] + public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + Metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + Metrics.AddMetadata("BookingId", "683EEB2D-B2F3-4075-96EE-788E6E2EED45"); + ... + ``` + +=== "Example CloudWatch Logs excerpt" + + ```json hl_lines="23" + { + "SuccessfulBooking": 1.0, + "_aws": { + "Timestamp": 1592234975665, + "CloudWatchMetrics": [ + { + "Namespace": "ExampleApplication", + "Dimensions": [ + [ + "service" + ] + ], + "Metrics": [ + { + "Name": "SuccessfulBooking", + "Unit": "Count" + } + ] + } + ] + }, + "Service": "Booking", + "BookingId": "683EEB2D-B2F3-4075-96EE-788E6E2EED45" + } + ``` + +### Single metric with a different dimension + +CloudWatch EMF uses the same dimensions across all your metrics. Use **`PushSingleMetric`** if you have a metric that should have different dimensions. + +!!! info + Generally, this would be an edge case since you [pay for unique metric](https://aws.amazon.com/cloudwatch/pricing). Keep the following formula in mind: + + **unique metric = (metric_name + dimension_name + dimension_value)** + +=== "Function.cs" + + ```csharp hl_lines="8-17" + using AWS.Lambda.Powertools.Metrics; + + public class Function { + + [Metrics(Namespace = ExampleApplication, Service = "Booking")] + public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + Metrics.PushSingleMetric( + metricName: "ColdStart", + value: 1, + unit: MetricUnit.Count, + nameSpace: "ExampleApplication", + service: "Booking", + defaultDimensions: new Dictionary + { + {"FunctionContext", "$LATEST"} + }); + ... + ``` + +## AspNetCore + +### Installation + +To use the Metrics middleware in an ASP.NET Core application, you need to install the `AWS.Lambda.Powertools.Metrics.AspNetCore` NuGet package. + +```bash +dotnet add package AWS.Lambda.Powertools.Metrics.AspNetCore +``` + +### UseMetrics() Middleware + +The `UseMetrics` middleware is an extension method for the `IApplicationBuilder` interface. + +It adds a metrics middleware to the specified application builder, which captures cold start metrics (if enabled) and flushes metrics on function exit. + +#### Example + +```csharp hl_lines="21" + +using AWS.Lambda.Powertools.Metrics.AspNetCore.Http; + +var builder = WebApplication.CreateBuilder(args); + +// Configure metrics +builder.Services.AddSingleton(_ => new MetricsBuilder() + .WithNamespace("MyApi") // Namespace for the metrics + .WithService("WeatherService") // Service name for the metrics + .WithCaptureColdStart(true) // Capture cold start metrics + .WithDefaultDimensions(new Dictionary // Default dimensions for the metrics + { + {"Environment", "Prod"}, + {"Another", "One"} + }) + .Build()); // Build the metrics + +builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi); + +var app = builder.Build(); + +app.UseMetrics(); // Add the metrics middleware + +app.MapGet("/powertools", (IMetrics metrics) => + { + // add custom metrics + metrics.AddMetric("MyCustomMetric", 1, MetricUnit.Count); + // flush metrics - this is required to ensure metrics are sent to CloudWatch + metrics.Flush(); + }); + +app.Run(); + +``` + +Here is the highlighted `UseMetrics` method: + +```csharp +/// +/// Adds a metrics middleware to the specified application builder. +/// This will capture cold start (if CaptureColdStart is enabled) metrics and flush metrics on function exit. +/// +/// The application builder to add the metrics middleware to. +/// The application builder with the metrics middleware added. +public static IApplicationBuilder UseMetrics(this IApplicationBuilder app) +{ + app.UseMiddleware(); + return app; +} +``` + +Explanation: + +- The method is defined as an extension method for the `IApplicationBuilder` interface. +- It adds a `MetricsMiddleware` to the application builder using the `UseMiddleware` method. +- The `MetricsMiddleware` captures and records metrics for HTTP requests, including cold start metrics if the `CaptureColdStart` option is enabled. + +### WithMetrics() filter + +The `WithMetrics` method is an extension method for the `RouteHandlerBuilder` class. + +It adds a metrics filter to the specified route handler builder, which captures cold start metrics (if enabled) and flushes metrics on function exit. + +#### Example + +```csharp hl_lines="31" + +using AWS.Lambda.Powertools.Metrics; +using AWS.Lambda.Powertools.Metrics.AspNetCore.Http; + +var builder = WebApplication.CreateBuilder(args); + +// Configure metrics +builder.Services.AddSingleton(_ => new MetricsBuilder() + .WithNamespace("MyApi") // Namespace for the metrics + .WithService("WeatherService") // Service name for the metrics + .WithCaptureColdStart(true) // Capture cold start metrics + .WithDefaultDimensions(new Dictionary // Default dimensions for the metrics + { + {"Environment", "Prod"}, + {"Another", "One"} + }) + .Build()); // Build the metrics + +// Add AWS Lambda support. When the application is run in Lambda, Kestrel is swapped out as the web server with Amazon.Lambda.AspNetCoreServer. This +// package will act as the web server translating requests and responses between the Lambda event source and ASP.NET Core. +builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi); + +var app = builder.Build(); + +app.MapGet("/powertools", (IMetrics metrics) => + { + // add custom metrics + metrics.AddMetric("MyCustomMetric", 1, MetricUnit.Count); + // flush metrics - this is required to ensure metrics are sent to CloudWatch + metrics.Flush(); + }) + .WithMetrics(); + +app.Run(); + +``` + +Here is the highlighted `WithMetrics` method: + +```csharp +/// +/// Adds a metrics filter to the specified route handler builder. +/// This will capture cold start (if CaptureColdStart is enabled) metrics and flush metrics on function exit. +/// +/// The route handler builder to add the metrics filter to. +/// The route handler builder with the metrics filter added. +public static RouteHandlerBuilder WithMetrics(this RouteHandlerBuilder builder) +{ + builder.AddEndpointFilter(); + return builder; +} +``` + +Explanation: + +- The method is defined as an extension method for the `RouteHandlerBuilder` class. +- It adds a `MetricsFilter` to the route handler builder using the `AddEndpointFilter` method. +- The `MetricsFilter` captures and records metrics for HTTP endpoints, including cold start metrics if the `CaptureColdStart` option is enabled. +- The method returns the modified `RouteHandlerBuilder` instance with the metrics filter added. + + +## Testing your code + +### Unit testing + +To test your code that uses the Metrics utility, you can use the `TestLambdaContext` class from the `Amazon.Lambda.TestUtilities` package. + +You can also use the `IMetrics` interface to mock the Metrics utility in your tests. + +Here is an example of how you can test a Lambda function that uses the Metrics utility: + +#### Lambda Function + +```csharp +using System.Collections.Generic; +using Amazon.Lambda.Core; + +public class MetricsnBuilderHandler +{ + private readonly IMetrics _metrics; + + // Allow injection of IMetrics for testing + public MetricsnBuilderHandler(IMetrics metrics = null) + { + _metrics = metrics ?? new MetricsBuilder() + .WithCaptureColdStart(true) + .WithService("testService") + .WithNamespace("dotnet-powertools-test") + .WithDefaultDimensions(new Dictionary + { + { "Environment", "Prod1" }, + { "Another", "One" } + }).Build(); + } + + [Metrics] + public void Handler(ILambdaContext context) + { + _metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + } +} + +``` +#### Unit Tests + + +```csharp +[Fact] + public void Handler_With_Builder_Should_Configure_In_Constructor() + { + // Arrange + var handler = new MetricsnBuilderHandler(); + + // Act + handler.Handler(new TestLambdaContext + { + FunctionName = "My_Function_Name" + }); + + // Get the output and parse it + var metricsOutput = _consoleOut.ToString(); + + // Assert cold start + Assert.Contains( + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\",\"FunctionName\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod1\",\"Another\":\"One\",\"FunctionName\":\"My_Function_Name\",\"ColdStart\":1}", + metricsOutput); + // Assert successful Memory metrics + Assert.Contains( + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"SuccessfulBooking\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\",\"FunctionName\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod1\",\"Another\":\"One\",\"FunctionName\":\"My_Function_Name\",\"SuccessfulBooking\":1}", + metricsOutput); + } + + [Fact] + public void Handler_With_Builder_Should_Configure_In_Constructor_Mock() + { + var metricsMock = Substitute.For(); + + metricsMock.Options.Returns(new MetricsOptions + { + CaptureColdStart = true, + Namespace = "dotnet-powertools-test", + Service = "testService", + DefaultDimensions = new Dictionary + { + { "Environment", "Prod" }, + { "Another", "One" } + } + }); + + Metrics.UseMetricsForTests(metricsMock); + + var sut = new MetricsnBuilderHandler(metricsMock); + + // Act + sut.Handler(new TestLambdaContext + { + FunctionName = "My_Function_Name" + }); + + metricsMock.Received(1).PushSingleMetric("ColdStart", 1, MetricUnit.Count, "dotnet-powertools-test", + service: "testService", Arg.Any>()); + metricsMock.Received(1).AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + } +``` + +### Environment variables + +???+ tip + Ignore this section, if: + + * You are explicitly setting namespace/default dimension via `namespace` and `service` parameters + * You're not instantiating `Metrics` in the global namespace + + For example, `Metrics(namespace="ExampleApplication", service="booking")` + +Make sure to set `POWERTOOLS_METRICS_NAMESPACE` and `POWERTOOLS_SERVICE_NAME` before running your tests to prevent failing on `SchemaValidation` exception. You can set it before you run tests by adding the environment variable. + +```csharp title="Injecting Metric Namespace before running tests" +Environment.SetEnvironmentVariable("POWERTOOLS_METRICS_NAMESPACE","AWSLambdaPowertools"); +``` diff --git a/docs/core/metrics.md b/docs/core/metrics.md index 65fb5f50..0a766414 100644 --- a/docs/core/metrics.md +++ b/docs/core/metrics.md @@ -109,7 +109,7 @@ You can create metrics using **`AddMetric`**, and you can create dimensions for === "Metrics" - ```csharp hl_lines="8" + ```csharp hl_lines="5 8" using AWS.Lambda.Powertools.Metrics; public class Function { diff --git a/libraries/AWS.Lambda.Powertools.sln b/libraries/AWS.Lambda.Powertools.sln index 72aea967..bcc1a2c9 100644 --- a/libraries/AWS.Lambda.Powertools.sln +++ b/libraries/AWS.Lambda.Powertools.sln @@ -97,6 +97,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AOT-FunctionHandlerTest", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AOT-FunctionMethodAttributeTest", "tests\e2e\functions\idempotency\AOT-Function\src\AOT-FunctionMethodAttributeTest\AOT-FunctionMethodAttributeTest.csproj", "{CC8CFF43-DC72-464C-A42D-55E023DE8500}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AWS.Lambda.Powertools.Metrics.AspNetCore", "src\AWS.Lambda.Powertools.Metrics.AspNetCore\AWS.Lambda.Powertools.Metrics.AspNetCore.csproj", "{A2AD98B1-2BED-4864-B573-77BE7B52FED2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AWS.Lambda.Powertools.Metrics.AspNetCore.Tests", "tests\AWS.Lambda.Powertools.Metrics.AspNetCore.Tests\AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj", "{F8F80477-1EAD-4C5C-A329-CBC0A60C7CAB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -518,6 +522,30 @@ Global {CC8CFF43-DC72-464C-A42D-55E023DE8500}.Release|x64.Build.0 = Release|Any CPU {CC8CFF43-DC72-464C-A42D-55E023DE8500}.Release|x86.ActiveCfg = Release|Any CPU {CC8CFF43-DC72-464C-A42D-55E023DE8500}.Release|x86.Build.0 = Release|Any CPU + {A2AD98B1-2BED-4864-B573-77BE7B52FED2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2AD98B1-2BED-4864-B573-77BE7B52FED2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2AD98B1-2BED-4864-B573-77BE7B52FED2}.Debug|x64.ActiveCfg = Debug|Any CPU + {A2AD98B1-2BED-4864-B573-77BE7B52FED2}.Debug|x64.Build.0 = Debug|Any CPU + {A2AD98B1-2BED-4864-B573-77BE7B52FED2}.Debug|x86.ActiveCfg = Debug|Any CPU + {A2AD98B1-2BED-4864-B573-77BE7B52FED2}.Debug|x86.Build.0 = Debug|Any CPU + {A2AD98B1-2BED-4864-B573-77BE7B52FED2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2AD98B1-2BED-4864-B573-77BE7B52FED2}.Release|Any CPU.Build.0 = Release|Any CPU + {A2AD98B1-2BED-4864-B573-77BE7B52FED2}.Release|x64.ActiveCfg = Release|Any CPU + {A2AD98B1-2BED-4864-B573-77BE7B52FED2}.Release|x64.Build.0 = Release|Any CPU + {A2AD98B1-2BED-4864-B573-77BE7B52FED2}.Release|x86.ActiveCfg = Release|Any CPU + {A2AD98B1-2BED-4864-B573-77BE7B52FED2}.Release|x86.Build.0 = Release|Any CPU + {F8F80477-1EAD-4C5C-A329-CBC0A60C7CAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F8F80477-1EAD-4C5C-A329-CBC0A60C7CAB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F8F80477-1EAD-4C5C-A329-CBC0A60C7CAB}.Debug|x64.ActiveCfg = Debug|Any CPU + {F8F80477-1EAD-4C5C-A329-CBC0A60C7CAB}.Debug|x64.Build.0 = Debug|Any CPU + {F8F80477-1EAD-4C5C-A329-CBC0A60C7CAB}.Debug|x86.ActiveCfg = Debug|Any CPU + {F8F80477-1EAD-4C5C-A329-CBC0A60C7CAB}.Debug|x86.Build.0 = Debug|Any CPU + {F8F80477-1EAD-4C5C-A329-CBC0A60C7CAB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F8F80477-1EAD-4C5C-A329-CBC0A60C7CAB}.Release|Any CPU.Build.0 = Release|Any CPU + {F8F80477-1EAD-4C5C-A329-CBC0A60C7CAB}.Release|x64.ActiveCfg = Release|Any CPU + {F8F80477-1EAD-4C5C-A329-CBC0A60C7CAB}.Release|x64.Build.0 = Release|Any CPU + {F8F80477-1EAD-4C5C-A329-CBC0A60C7CAB}.Release|x86.ActiveCfg = Release|Any CPU + {F8F80477-1EAD-4C5C-A329-CBC0A60C7CAB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution @@ -563,5 +591,7 @@ Global {ACA789EA-BD38-490B-A7F8-6A3A86985025} = {FB2C7DA3-6FCE-429D-86F9-5775D0231EC6} {E71C48D2-AD56-4177-BBD7-6BB859A40C92} = {FB2C7DA3-6FCE-429D-86F9-5775D0231EC6} {CC8CFF43-DC72-464C-A42D-55E023DE8500} = {FB2C7DA3-6FCE-429D-86F9-5775D0231EC6} + {A2AD98B1-2BED-4864-B573-77BE7B52FED2} = {73C9B1E5-3893-47E8-B373-17E5F5D7E6F5} + {F8F80477-1EAD-4C5C-A329-CBC0A60C7CAB} = {1CFF5568-8486-475F-81F6-06105C437528} EndGlobalSection EndGlobal diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/AWS.Lambda.Powertools.Metrics.AspNetCore.csproj b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/AWS.Lambda.Powertools.Metrics.AspNetCore.csproj new file mode 100644 index 00000000..529fd973 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/AWS.Lambda.Powertools.Metrics.AspNetCore.csproj @@ -0,0 +1,23 @@ + + + + AWS.Lambda.Powertools.Metrics.AspNetCore + Powertools for AWS Lambda (.NET) - Metrics AspNetCore package. + AWS.Lambda.Powertools.Metrics.AspNetCore + AWS.Lambda.Powertools.Metrics.AspNetCore + net8.0;net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsEndpointExtensions.cs b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsEndpointExtensions.cs new file mode 100644 index 00000000..a2101229 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsEndpointExtensions.cs @@ -0,0 +1,37 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Http; + +/// +/// Provides extension methods for adding metrics to route handlers. +/// +public static class MetricsEndpointExtensions +{ + /// + /// Adds a metrics filter to the specified route handler builder. + /// This will capture cold start (if CaptureColdStart is enabled) metrics and flush metrics on function exit. + /// + /// The route handler builder to add the metrics filter to. + /// The route handler builder with the metrics filter added. + public static RouteHandlerBuilder WithMetrics(this RouteHandlerBuilder builder) + { + builder.AddEndpointFilter(); + return builder; + } +} diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsFilter.cs b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsFilter.cs new file mode 100644 index 00000000..a2c776f1 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsFilter.cs @@ -0,0 +1,57 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using Microsoft.AspNetCore.Http; + +namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Http; + +/// +/// Represents a filter that captures and records metrics for HTTP endpoints. +/// +public class MetricsFilter : IEndpointFilter +{ + private readonly MetricsHelper _metricsHelper; + + /// + /// Initializes a new instance of the class. + /// + /// The metrics instance to use for recording metrics. + public MetricsFilter(IMetrics metrics) + { + _metricsHelper = new MetricsHelper(metrics); + } + + /// + /// Invokes the filter asynchronously. + /// + /// The context for the endpoint filter invocation. + /// The delegate to invoke the next filter or endpoint. + /// A task that represents the asynchronous operation, containing the result of the endpoint invocation. + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var result = await next(context); + + try + { + await _metricsHelper.CaptureColdStartMetrics(context.HttpContext); + return result; + } + catch + { + // ignored + return result; + } + } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsHelper.cs b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsHelper.cs new file mode 100644 index 00000000..3a4e0357 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsHelper.cs @@ -0,0 +1,76 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using Amazon.Lambda.Core; +using Microsoft.AspNetCore.Http; + +namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Http; + + +/// +/// Helper class for capturing and recording metrics in ASP.NET Core applications. +/// +public class MetricsHelper +{ + private readonly IMetrics _metrics; + private static bool _isColdStart = true; + + /// + /// Initializes a new instance of the class. + /// + /// The metrics instance to use for recording metrics. + public MetricsHelper(IMetrics metrics) + { + _metrics = metrics; + } + + /// + /// Captures cold start metrics for the given HTTP context. + /// + /// The HTTP context. + /// A task that represents the asynchronous operation. + public Task CaptureColdStartMetrics(HttpContext context) + { + if (_metrics.Options.CaptureColdStart == null || !_metrics.Options.CaptureColdStart.Value || !_isColdStart) + return Task.CompletedTask; + + var defaultDimensions = _metrics.Options.DefaultDimensions; + _isColdStart = false; + + if (context.Items["LambdaContext"] is ILambdaContext lambdaContext) + { + defaultDimensions?.Add("FunctionName", lambdaContext.FunctionName); + _metrics.SetDefaultDimensions(defaultDimensions); + } + + _metrics.PushSingleMetric( + "ColdStart", + 1.0, + MetricUnit.Count, + _metrics.Options.Namespace, + _metrics.Options.Service, + defaultDimensions + ); + return Task.CompletedTask; + } + + /// + /// Resets the cold start flag for testing purposes. + /// + internal static void ResetColdStart() + { + _isColdStart = true; + } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsMiddlewareExtensions.cs b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsMiddlewareExtensions.cs new file mode 100644 index 00000000..05e0d3f9 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsMiddlewareExtensions.cs @@ -0,0 +1,41 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Http; + +/// +/// Provides extension methods for adding metrics middleware to the application pipeline. +/// +public static class MetricsMiddlewareExtensions +{ + /// + /// Adds middleware to capture and record metrics for HTTP requests. + /// + /// The application builder. + /// The application builder with the metrics middleware added. + public static IApplicationBuilder UseMetrics(this IApplicationBuilder app) + { + return app.Use(async (context, next) => + { + var metrics = context.RequestServices.GetRequiredService(); + var metricsHelper = new MetricsHelper(metrics); + await metricsHelper.CaptureColdStartMetrics(context); + await next(); + }); + } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/InternalsVisibleTo.cs b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/InternalsVisibleTo.cs new file mode 100644 index 00000000..5b9c15a1 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/InternalsVisibleTo.cs @@ -0,0 +1,18 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("AWS.Lambda.Powertools.Metrics.AspNetCore.Tests")] \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/README.md b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/README.md new file mode 100644 index 00000000..a1ca8fee --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/README.md @@ -0,0 +1,149 @@ +# AWS Lambda Powertools Metrics for ASP.NET Core + +This library provides utilities for capturing and publishing custom metrics from your AWS Lambda functions using ASP.NET Core. + +## Getting Started + +This library provides utilities for capturing and publishing custom metrics from your AWS Lambda functions using ASP.NET Core. + +### Installation + +You can install the package via the NuGet package manager just search for `AWS.Lambda.Powertools.Metrics.AspNetCore`. + +You can also install via powershell using the following command. + +```shell +dotnet add package AWS.Lambda.Powertools.Metrics.AspNetCore +``` + +```csharp + +using AWS.Lambda.Powertools.Metrics; +using AWS.Lambda.Powertools.Metrics.AspNetCore.Http; + +var builder = WebApplication.CreateBuilder(args); + +// Configure metrics +builder.Services.AddSingleton(_ => new MetricsBuilder() + .WithNamespace("MyApi") // Namespace for the metrics + .WithService("WeatherService") // Service name for the metrics + .WithCaptureColdStart(true) // Capture cold start metrics + .WithDefaultDimensions(new Dictionary // Default dimensions for the metrics + { + {"Environment", "Prod"}, + {"Another", "One"} + }) + .Build()); // Build the metrics + +// Add AWS Lambda support. When the application is run in Lambda, Kestrel is swapped out as the web server with Amazon.Lambda.AspNetCoreServer. This +// package will act as the web server translating requests and responses between the Lambda event source and ASP.NET Core. +builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi); + +var app = builder.Build(); + +app.MapGet("/powertools", (IMetrics metrics) => + { + // add custom metrics + metrics.AddMetric("MyCustomMetric", 1, MetricUnit.Count); + // flush metrics - this is required to ensure metrics are sent to CloudWatch + metrics.Flush(); + }) + .WithMetrics(); + +app.Run(); + +``` + +### WithMetrics() filter + +The `WithMetrics` method is an extension method for the `RouteHandlerBuilder` class. + +It adds a metrics filter to the specified route handler builder, which captures cold start metrics (if enabled) and flushes metrics on function exit. + +Here is the highlighted `WithMetrics` method: + +```csharp +/// +/// Adds a metrics filter to the specified route handler builder. +/// This will capture cold start (if CaptureColdStart is enabled) metrics and flush metrics on function exit. +/// +/// The route handler builder to add the metrics filter to. +/// The route handler builder with the metrics filter added. +public static RouteHandlerBuilder WithMetrics(this RouteHandlerBuilder builder) +{ + builder.AddEndpointFilter(); + return builder; +} +``` + +Explanation: +- The method is defined as an extension method for the `RouteHandlerBuilder` class. +- It adds a `MetricsFilter` to the route handler builder using the `AddEndpointFilter` method. +- The `MetricsFilter` captures and records metrics for HTTP endpoints, including cold start metrics if the `CaptureColdStart` option is enabled. +- The method returns the modified `RouteHandlerBuilder` instance with the metrics filter added. + + +### UseMetrics() Middleware + +The `UseMetrics` middleware is an extension method for the `IApplicationBuilder` interface. + +It adds a metrics middleware to the specified application builder, which captures cold start metrics (if enabled) and flushes metrics on function exit. + +#### Example + +```csharp + +using AWS.Lambda.Powertools.Metrics.AspNetCore.Http; + +var builder = WebApplication.CreateBuilder(args); + +// Configure metrics +builder.Services.AddSingleton(_ => new MetricsBuilder() + .WithNamespace("MyApi") // Namespace for the metrics + .WithService("WeatherService") // Service name for the metrics + .WithCaptureColdStart(true) // Capture cold start metrics + .WithDefaultDimensions(new Dictionary // Default dimensions for the metrics + { + {"Environment", "Prod"}, + {"Another", "One"} + }) + .Build()); // Build the metrics + +builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi); + +var app = builder.Build(); + +app.UseMetrics(); // Add the metrics middleware + +app.MapGet("/powertools", (IMetrics metrics) => + { + // add custom metrics + metrics.AddMetric("MyCustomMetric", 1, MetricUnit.Count); + // flush metrics - this is required to ensure metrics are sent to CloudWatch + metrics.Flush(); + }); + +app.Run(); + +``` + +Here is the highlighted `UseMetrics` method: + +```csharp +/// +/// Adds a metrics middleware to the specified application builder. +/// This will capture cold start (if CaptureColdStart is enabled) metrics and flush metrics on function exit. +/// +/// The application builder to add the metrics middleware to. +/// The application builder with the metrics middleware added. +public static IApplicationBuilder UseMetrics(this IApplicationBuilder app) +{ + app.UseMiddleware(); + return app; +} +``` + +Explanation: +- The method is defined as an extension method for the `IApplicationBuilder` interface. +- It adds a `MetricsMiddleware` to the application builder using the `UseMiddleware` method. +- The `MetricsMiddleware` captures and records metrics for HTTP requests, including cold start metrics if the `CaptureColdStart` option is enabled. \ No newline at end of file diff --git a/libraries/src/Directory.Packages.props b/libraries/src/Directory.Packages.props index 56d0fba9..c5af6311 100644 --- a/libraries/src/Directory.Packages.props +++ b/libraries/src/Directory.Packages.props @@ -11,6 +11,8 @@ + + diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj new file mode 100644 index 00000000..c336bde1 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj @@ -0,0 +1,32 @@ + + + + AWS.Lambda.Powertools.Metrics.AspNetCore.Tests + AWS.Lambda.Powertools.Metrics.AspNetCore.Tests + net8.0 + enable + enable + + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsFilterTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsFilterTests.cs new file mode 100644 index 00000000..6a0df634 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsFilterTests.cs @@ -0,0 +1,110 @@ +using Amazon.Lambda.Core; +using AWS.Lambda.Powertools.Metrics.AspNetCore.Http; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Xunit; + +namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Tests; + +public class MetricsFilterTests +{ + private readonly IMetrics _metrics; + private readonly EndpointFilterInvocationContext _context; + private readonly ILambdaContext _lambdaContext; + + public MetricsFilterTests() + { + MetricsHelper.ResetColdStart(); // Reset before each test + _metrics = Substitute.For(); + _context = Substitute.For(); + _lambdaContext = Substitute.For(); + + var httpContext = new DefaultHttpContext(); + httpContext.Items["LambdaContext"] = _lambdaContext; + _context.HttpContext.Returns(httpContext); + } + + [Fact] + public async Task InvokeAsync_WhenColdStartEnabled_RecordsColdStartMetric() + { + // Arrange + var options = new MetricsOptions + { + CaptureColdStart = true, + Namespace = "TestNamespace", + Service = "TestService", + DefaultDimensions = new Dictionary() + }; + + _metrics.Options.Returns(options); + _lambdaContext.FunctionName.Returns("TestFunction"); + + var filter = new MetricsFilter(_metrics); + var next = new EndpointFilterDelegate(_ => ValueTask.FromResult("result")); + + // Act + var result = await filter.InvokeAsync(_context, next); + + // Assert + _metrics.Received(1).PushSingleMetric( + "ColdStart", + 1.0, + MetricUnit.Count, + "TestNamespace", + "TestService", + Arg.Any>() + ); + Assert.Equal("result", result); + } + + [Fact] + public async Task InvokeAsync_WhenColdStartDisabled_DoesNotRecordMetric() + { + // Arrange + var options = new MetricsOptions { CaptureColdStart = false }; + _metrics.Options.Returns(options); + + var filter = new MetricsFilter(_metrics); + var next = new EndpointFilterDelegate(_ => ValueTask.FromResult("result")); + + // Act + var result = await filter.InvokeAsync(_context, next); + + // Assert + _metrics.DidNotReceive().PushSingleMetric( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>() + ); + Assert.Equal("result", result); + } + + [Fact] + public async Task InvokeAsync_ShouldCallNextAndContinue() + { + // Arrange + var metrics = Substitute.For(); + metrics.Options.Returns(new MetricsOptions { CaptureColdStart = true }); + + var httpContext = new DefaultHttpContext(); + var context = new DefaultEndpointFilterInvocationContext(httpContext); + var filter = new MetricsFilter(metrics); + + var called = false; + EndpointFilterDelegate next = _ => + { + called = true; + return ValueTask.FromResult("result"); + }; + + // Act + var result = await filter.InvokeAsync(context, next); + + // Assert + Assert.True(called); + Assert.Equal("result", result); + } +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsHelperTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsHelperTests.cs new file mode 100644 index 00000000..37064d24 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsHelperTests.cs @@ -0,0 +1,84 @@ +using System.Reflection; +using AWS.Lambda.Powertools.Metrics.AspNetCore.Http; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using NSubstitute; +using Xunit; + +namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Tests; + +public class MetricsHelperTests +{ + [Fact] + public async Task CaptureColdStartMetrics_WhenEnabled_ShouldPushMetric() + { + // Arrange + var metrics = Substitute.For(); + metrics.Options.Returns(new MetricsOptions + { + CaptureColdStart = true, + Namespace = "TestNamespace", + Service = "TestService" + }); + + var context = new DefaultHttpContext(); + var helper = new MetricsHelper(metrics); + + // Act + await helper.CaptureColdStartMetrics(context); + + // Assert + metrics.Received(1).PushSingleMetric( + Arg.Is(s => s == "ColdStart"), + Arg.Is(d => d == 1.0), + Arg.Is(u => u == MetricUnit.Count), + Arg.Is(s => s == "TestNamespace"), + Arg.Is(s => s == "TestService"), + Arg.Any>() + ); + } + + [Fact] + public async Task CaptureColdStartMetrics_WhenDisabled_ShouldNotPushMetric() + { + // Arrange + var metrics = Substitute.For(); + metrics.Options.Returns(new MetricsOptions { CaptureColdStart = false }); + + var context = new DefaultHttpContext(); + var helper = new MetricsHelper(metrics); + + // Act + await helper.CaptureColdStartMetrics(context); + + // Assert + metrics.DidNotReceive().PushSingleMetric( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>() + ); + } +} + +public static class EndpointFilterInvocationContextHelper +{ + public static EndpointFilterInvocationContext Create(HttpContext httpContext, object[] arguments) + { + var endpoint = new RouteEndpoint( + c => Task.CompletedTask, + RoutePatternFactory.Parse("/"), + 0, + EndpointMetadataCollection.Empty, + "test"); + + var constructor = typeof(EndpointFilterInvocationContext) + .GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance) + .First(); + + return (EndpointFilterInvocationContext)constructor.Invoke(new object[] { httpContext, endpoint, arguments }); + } +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsMiddlewareExtensionsTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsMiddlewareExtensionsTests.cs new file mode 100644 index 00000000..3d5a9892 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsMiddlewareExtensionsTests.cs @@ -0,0 +1,99 @@ +using AWS.Lambda.Powertools.Metrics.AspNetCore.Http; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Xunit; + +namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Tests; + +public class MetricsMiddlewareExtensionsTests : IDisposable +{ + public MetricsMiddlewareExtensionsTests() + { + MetricsHelper.ResetColdStart(); + } + + public void Dispose() + { + MetricsHelper.ResetColdStart(); + } + + [Fact] + public async Task UseMetrics_ShouldCaptureColdStart_WhenEnabled() + { + // Arrange + var metrics = Substitute.For(); + metrics.Options.Returns(new MetricsOptions + { + CaptureColdStart = true, + Namespace = "TestNamespace", + Service = "TestService" + }); + + var services = new ServiceCollection(); + services.AddSingleton(metrics); + var serviceProvider = services.BuildServiceProvider(); + + var context = new DefaultHttpContext + { + RequestServices = serviceProvider + }; + + var appBuilder = new ApplicationBuilder(serviceProvider); + appBuilder.UseMetrics(); + var app = appBuilder.Build(); + + // Act + await app.Invoke(context); + + // Assert + metrics.Received(1).PushSingleMetric( + Arg.Is(s => s == "ColdStart"), + Arg.Is(d => d == 1.0), + Arg.Is(u => u == MetricUnit.Count), + Arg.Is(s => s == "TestNamespace"), + Arg.Is(s => s == "TestService"), + Arg.Any>() + ); + } + + [Fact] + public async Task UseMetrics_ShouldNotCaptureColdStart_WhenDisabled() + { + // Arrange + var metrics = Substitute.For(); + metrics.Options.Returns(new MetricsOptions + { + CaptureColdStart = false, + Namespace = "TestNamespace", + Service = "TestService" + }); + + var services = new ServiceCollection(); + services.AddSingleton(metrics); + var serviceProvider = services.BuildServiceProvider(); + + var context = new DefaultHttpContext + { + RequestServices = serviceProvider + }; + + var appBuilder = new ApplicationBuilder(serviceProvider); + appBuilder.UseMetrics(); + var app = appBuilder.Build(); + + // Act + await app.Invoke(context); + + // Assert + metrics.DidNotReceive().PushSingleMetric( + Arg.Is(s => s == "ColdStart"), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>() + ); + } +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs index 1028f58c..95e6a9f3 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs @@ -9,6 +9,9 @@ public DefaultDimensionsHandler() { Metrics.Configure(options => { + options.Namespace = "dotnet-powertools-test"; + options.Service = "testService"; + options.CaptureColdStart = true; options.DefaultDimensions = new Dictionary { { "Environment", "Prod" }, @@ -17,14 +20,14 @@ public DefaultDimensionsHandler() }); } - [Metrics(Namespace = "dotnet-powertools-test", Service = "testService", CaptureColdStart = true)] + [Metrics] public void Handler() { // Default dimensions are already set Metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); } - [Metrics(Namespace = "dotnet-powertools-test", Service = "testService", CaptureColdStart = true)] + [Metrics] public void HandlerWithContext(ILambdaContext context) { // Default dimensions are already set and adds FunctionName dimension diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs index 36a5818e..dc338d59 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs @@ -145,11 +145,11 @@ public void DefaultDimensions_AreAppliedCorrectly() // Assert cold start Assert.Contains( - "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Environment\",\"Another\",\"Service\"]]}]},\"Environment\":\"Prod\",\"Another\":\"One\",\"Service\":\"testService\",\"ColdStart\":1}", + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod\",\"Another\":\"One\",\"ColdStart\":1}", metricsOutput); // Assert successful booking metrics Assert.Contains( - "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"SuccessfulBooking\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Environment\",\"Another\",\"Service\"]]}]},\"Environment\":\"Prod\",\"Another\":\"One\",\"Service\":\"testService\",\"SuccessfulBooking\":1}", + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"SuccessfulBooking\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod\",\"Another\":\"One\",\"SuccessfulBooking\":1}", metricsOutput); } @@ -170,11 +170,11 @@ public void DefaultDimensions_AreAppliedCorrectly_WithContext_FunctionName() // Assert cold start Assert.Contains( - "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Environment\",\"Another\",\"Service\",\"FunctionName\"]]}]},\"Environment\":\"Prod\",\"Another\":\"One\",\"Service\":\"testService\",\"FunctionName\":\"My_Function_Name\",\"ColdStart\":1}", + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\",\"FunctionName\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod\",\"Another\":\"One\",\"FunctionName\":\"My_Function_Name\",\"ColdStart\":1}", metricsOutput); // Assert successful Memory metrics Assert.Contains( - "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"Memory\",\"Unit\":\"Megabytes\"}],\"Dimensions\":[[\"Environment\",\"Another\",\"Service\",\"FunctionName\"]]}]},\"Environment\":\"Prod\",\"Another\":\"One\",\"Service\":\"testService\",\"FunctionName\":\"My_Function_Name\",\"Memory\":10}", + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"Memory\",\"Unit\":\"Megabytes\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\",\"FunctionName\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod\",\"Another\":\"One\",\"FunctionName\":\"My_Function_Name\",\"Memory\":10}", metricsOutput); } diff --git a/mkdocs.yml b/mkdocs.yml index c804a353..d1449647 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,6 +15,7 @@ nav: - Core utilities: - core/logging.md - core/metrics.md + - core/metrics-v2.md - core/tracing.md - Utilities: - utilities/parameters.md From 1756ce1d47aad605d28cb4b8828532eca3aadb2d Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Sat, 22 Feb 2025 12:00:53 +0000 Subject: [PATCH 02/29] refactor(metrics): standardize parameter names for metric methods to improve clarity --- .../AWS.Lambda.Powertools.Metrics/Metrics.cs | 38 +++++++++---------- .../Handlers/FunctionHandler.cs | 8 ++-- .../Function/src/Function/TestHelper.cs | 2 +- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs index c6c0e3be..86823bf0 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs @@ -133,7 +133,7 @@ internal Metrics(IPowertoolsConfigurations powertoolsConfigurations, string name } /// - void IMetrics.AddMetric(string key, double value, MetricUnit unit, MetricResolution metricResolution) + void IMetrics.AddMetric(string key, double value, MetricUnit unit, MetricResolution resolution) { if (Instance != null) { @@ -160,7 +160,7 @@ void IMetrics.AddMetric(string key, double value, MetricUnit unit, MetricResolut Instance.Flush(true); } - _context.AddMetric(key, value, unit, metricResolution); + _context.AddMetric(key, value, unit, resolution); } } else @@ -216,14 +216,14 @@ void IMetrics.AddMetadata(string key, object value) } /// - void IMetrics.SetDefaultDimensions(Dictionary defaultDimension) + void IMetrics.SetDefaultDimensions(Dictionary defaultDimensions) { - foreach (var item in defaultDimension) + foreach (var item in defaultDimensions) if (string.IsNullOrWhiteSpace(item.Key) || string.IsNullOrWhiteSpace(item.Value)) throw new ArgumentNullException(nameof(item.Key), "'SetDefaultDimensions' method requires a valid key pair. 'Null' or empty values are not allowed."); - _context.SetDefaultDimensions(DictionaryToList(defaultDimension)); + _context.SetDefaultDimensions(DictionaryToList(defaultDimensions)); } /// @@ -294,11 +294,11 @@ private Dictionary GetDefaultDimensions() } /// - void IMetrics.PushSingleMetric(string metricName, double value, MetricUnit unit, string nameSpace, - string service, Dictionary defaultDimensions, MetricResolution metricResolution) + void IMetrics.PushSingleMetric(string name, double value, MetricUnit unit, string nameSpace, + string service, Dictionary defaultDimensions, MetricResolution resolution) { - if (string.IsNullOrWhiteSpace(metricName)) - throw new ArgumentNullException(nameof(metricName), + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentNullException(nameof(name), "'PushSingleMetric' method requires a valid metrics key. 'Null' or empty values are not allowed."); var context = new MetricsContext(); @@ -311,7 +311,7 @@ void IMetrics.PushSingleMetric(string metricName, double value, MetricUnit unit, context.SetDefaultDimensions(defaultDimensionsList); } - context.AddMetric(metricName, value, unit, metricResolution); + context.AddMetric(name, value, unit, resolution); Flush(context); } @@ -345,11 +345,11 @@ protected virtual void Dispose(bool disposing) /// Metric Key. Must not be null, empty or whitespace /// Metric Value /// Metric Unit - /// + /// public static void AddMetric(string key, double value, MetricUnit unit = MetricUnit.None, - MetricResolution metricResolution = MetricResolution.Default) + MetricResolution resolution = MetricResolution.Default) { - Instance.AddMetric(key, value, unit, metricResolution); + Instance.AddMetric(key, value, unit, resolution); } /// @@ -438,21 +438,21 @@ private void Flush(MetricsContext context) /// Pushes single metric to CloudWatch using Embedded Metric Format. This can be used to push metrics with a different /// context. /// - /// Metric Name. Metric key cannot be null, empty or whitespace + /// Metric Name. Metric key cannot be null, empty or whitespace /// Metric Value /// Metric Unit /// Metric Namespace /// Service Name /// Default dimensions list - /// Metrics resolution - public static void PushSingleMetric(string metricName, double value, MetricUnit unit, string nameSpace = null, + /// Metrics resolution + public static void PushSingleMetric(string name, double value, MetricUnit unit, string nameSpace = null, string service = null, Dictionary defaultDimensions = null, - MetricResolution metricResolution = MetricResolution.Default) + MetricResolution resolution = MetricResolution.Default) { if (Instance != null) { - Instance.PushSingleMetric(metricName, value, unit, nameSpace, service, defaultDimensions, - metricResolution); + Instance.PushSingleMetric(name, value, unit, nameSpace, service, defaultDimensions, + resolution); } else { diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs index f00a8c5f..d860a9f9 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs @@ -42,12 +42,12 @@ public void AddDimensions() [Metrics(Namespace = "dotnet-powertools-test", Service = "ServiceName", CaptureColdStart = true)] public void AddMultipleDimensions() { - Metrics.PushSingleMetric("SingleMetric1", 1, MetricUnit.Count, metricResolution: MetricResolution.High, + Metrics.PushSingleMetric("SingleMetric1", 1, MetricUnit.Count, resolution: MetricResolution.High, defaultDimensions: new Dictionary { { "Default1", "SingleMetric1" } }); - Metrics.PushSingleMetric("SingleMetric2", 1, MetricUnit.Count, metricResolution: MetricResolution.High, nameSpace: "ns2", + Metrics.PushSingleMetric("SingleMetric2", 1, MetricUnit.Count, resolution: MetricResolution.High, nameSpace: "ns2", defaultDimensions: new Dictionary { { "Default1", "SingleMetric2" }, { "Default2", "SingleMetric2" } @@ -59,7 +59,7 @@ public void AddMultipleDimensions() [Metrics(Namespace = "ExampleApplication")] public void PushSingleMetricWithNamespace() { - Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count, metricResolution: MetricResolution.High, + Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count, resolution: MetricResolution.High, defaultDimensions: new Dictionary { { "Default", "SingleMetric" } }); @@ -68,7 +68,7 @@ public void PushSingleMetricWithNamespace() [Metrics] public void PushSingleMetricWithEnvNamespace() { - Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count, metricResolution: MetricResolution.High, + Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count, resolution: MetricResolution.High, defaultDimensions: new Dictionary { { "Default", "SingleMetric" } }); diff --git a/libraries/tests/e2e/functions/core/metrics/Function/src/Function/TestHelper.cs b/libraries/tests/e2e/functions/core/metrics/Function/src/Function/TestHelper.cs index 750c77ab..c3434d28 100644 --- a/libraries/tests/e2e/functions/core/metrics/Function/src/Function/TestHelper.cs +++ b/libraries/tests/e2e/functions/core/metrics/Function/src/Function/TestHelper.cs @@ -33,7 +33,7 @@ public static void TestMethod(APIGatewayProxyRequest apigwProxyEvent, ILambdaCon Metrics.AddMetadata("RequestId", apigwProxyEvent.RequestContext.RequestId); Metrics.PushSingleMetric( - metricName: "SingleMetric", + name: "SingleMetric", value: 1, unit: MetricUnit.Count, nameSpace: "Test", From e6f6be62b86bc3c04ccfc21da8d3ef4e69d8474b Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Sat, 22 Feb 2025 12:04:34 +0000 Subject: [PATCH 03/29] fix(metrics): ensure thread safety by locking metrics during cold start flag reset --- .../Http/MetricsHelper.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsHelper.cs b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsHelper.cs index 3a4e0357..250caef8 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsHelper.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsHelper.cs @@ -47,7 +47,10 @@ public Task CaptureColdStartMetrics(HttpContext context) return Task.CompletedTask; var defaultDimensions = _metrics.Options.DefaultDimensions; - _isColdStart = false; + lock (_metrics) + { + _isColdStart = false; + } if (context.Items["LambdaContext"] is ILambdaContext lambdaContext) { From 0ecffb8b192e3c155c3c103767d4f277b1483054 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Sat, 22 Feb 2025 12:26:04 +0000 Subject: [PATCH 04/29] fix(tests): conditionally include project reference for net8.0 framework --- .../AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj index c336bde1..b2f6d2dc 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj @@ -26,7 +26,8 @@ - + From a3e6a31b0face6e4b9f742025a6eb83314665e29 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Sat, 22 Feb 2025 13:01:20 +0000 Subject: [PATCH 05/29] feat(build): enhance CI configuration with multi-framework support for .NET 6.0 and 8.0 --- .github/workflows/build.yml | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cfc6045b..d552fa7c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,27 +20,42 @@ permissions: jobs: build: runs-on: ubuntu-latest - + strategy: + matrix: + dotnet-version: ['6.0.x', '8.0.x'] + include: + - dotnet-version: '6.0.x' + framework: 'net6.0' + - dotnet-version: '8.0.x' + framework: 'net8.0' + fail-fast: false + steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Setup .NET 6.0 & 8.0 + + - name: Setup .NET ${{ dotnet-version }} uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # 4.3.0 with: - dotnet-version: | - 6.0.405 - 8.0.101 + dotnet-version: ${{ dotnet-version }} + + - name: Install dependencies + run: dotnet restore -f ${{ matrix.framework }} + - name: Build - run: dotnet build --configuration Release + run: dotnet build --configuration Release --no-restore -f ${{ matrix.framework }} + - name: Test Examples run: dotnet test ../examples/ + - name: Test & Code Coverage - run: dotnet test --filter "Category!=E2E" --collect:"XPlat Code Coverage" --results-directory ./codecov --verbosity normal + run: dotnet test --no-restore -f ${{ matrix.framework }} --filter "Category!=E2E" --collect:"XPlat Code Coverage" --results-directory ./codecov/${{ matrix.framework }} --verbosity quiet + - name: Codecov uses: codecov/codecov-action@13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3 # 5.3.1 with: token: ${{ secrets.CODECOV_TOKEN }} - flags: unittests + flags: ${{ matrix.framework }} fail_ci_if_error: false - name: codecov-lambda-powertools-dotnet + name: codecov-lambda-powertools-dotnet-${{ matrix.framework }} verbose: true - directory: ./libraries/codecov + directory: ./libraries/codecov/${{ matrix.framework }} From c1936a515715afa07520b22c1119714e30ddd32c Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Sat, 22 Feb 2025 13:02:56 +0000 Subject: [PATCH 06/29] fix(build): update .NET setup step to use matrix variable for versioning --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d552fa7c..f70e241c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,10 +33,10 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Setup .NET ${{ dotnet-version }} + - name: Setup .NET ${{ matrix.dotnet-version }} uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # 4.3.0 with: - dotnet-version: ${{ dotnet-version }} + dotnet-version: ${{ matrix.dotnet-version }} - name: Install dependencies run: dotnet restore -f ${{ matrix.framework }} From 910f28667eddf35b5ec1b9eb78e305a3968a3136 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Sat, 22 Feb 2025 13:04:04 +0000 Subject: [PATCH 07/29] fix(build): simplify dependency installation step in CI configuration --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f70e241c..970d3c2b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,7 +39,7 @@ jobs: dotnet-version: ${{ matrix.dotnet-version }} - name: Install dependencies - run: dotnet restore -f ${{ matrix.framework }} + run: dotnet restore - name: Build run: dotnet build --configuration Release --no-restore -f ${{ matrix.framework }} From 9f6f46027b2f992b9924207dc3988c1429bc788a Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Sat, 22 Feb 2025 13:13:06 +0000 Subject: [PATCH 08/29] fix(build): pass target framework properties during restore, build, and test steps --- .github/workflows/build.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 970d3c2b..3d0350ed 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,8 +26,10 @@ jobs: include: - dotnet-version: '6.0.x' framework: 'net6.0' + build-props: '/p:TargetFramework=net6.0' - dotnet-version: '8.0.x' framework: 'net8.0' + build-props: '/p:TargetFramework=net8.0' fail-fast: false steps: @@ -39,16 +41,16 @@ jobs: dotnet-version: ${{ matrix.dotnet-version }} - name: Install dependencies - run: dotnet restore + run: dotnet restore ${{ matrix.build-props }} - name: Build - run: dotnet build --configuration Release --no-restore -f ${{ matrix.framework }} + run: dotnet build --configuration Release --no-restore ${{ matrix.build-props }} - name: Test Examples - run: dotnet test ../examples/ + run: dotnet test ../examples/ ${{ matrix.build-props }} --verbosity quiet - name: Test & Code Coverage - run: dotnet test --no-restore -f ${{ matrix.framework }} --filter "Category!=E2E" --collect:"XPlat Code Coverage" --results-directory ./codecov/${{ matrix.framework }} --verbosity quiet + run: dotnet test --no-restore ${{ matrix.build-props }} --filter "Category!=E2E" --collect:"XPlat Code Coverage" --results-directory ./codecov/${{ matrix.framework }} --verbosity quiet - name: Codecov uses: codecov/codecov-action@13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3 # 5.3.1 From 491338e3d4f1f0acc90beaa1762afcf4926bacd8 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Sat, 22 Feb 2025 13:22:16 +0000 Subject: [PATCH 09/29] fix(build): add SkipInvalidProjects property to build properties for .NET frameworks --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3d0350ed..a0122d2e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,10 +26,10 @@ jobs: include: - dotnet-version: '6.0.x' framework: 'net6.0' - build-props: '/p:TargetFramework=net6.0' + build-props: '/p:TargetFramework=net6.0 /p:SkipInvalidProjects=true' - dotnet-version: '8.0.x' framework: 'net8.0' - build-props: '/p:TargetFramework=net8.0' + build-props: '/p:TargetFramework=net8.0 /p:SkipInvalidProjects=true' fail-fast: false steps: From 60d6c64ccc8ba660ae927a162d53c20c8eb7e1c8 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Sat, 22 Feb 2025 16:14:29 +0000 Subject: [PATCH 10/29] revert to single job --- .github/workflows/build.yml | 42 +++++++++++++------------------------ 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a0122d2e..ff2fb598 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,44 +20,30 @@ permissions: jobs: build: runs-on: ubuntu-latest - strategy: - matrix: - dotnet-version: ['6.0.x', '8.0.x'] - include: - - dotnet-version: '6.0.x' - framework: 'net6.0' - build-props: '/p:TargetFramework=net6.0 /p:SkipInvalidProjects=true' - - dotnet-version: '8.0.x' - framework: 'net8.0' - build-props: '/p:TargetFramework=net8.0 /p:SkipInvalidProjects=true' - fail-fast: false - steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Setup .NET ${{ matrix.dotnet-version }} + + - name: Setup .NET SDK uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # 4.3.0 with: - dotnet-version: ${{ matrix.dotnet-version }} - + dotnet-version: | + 6.0.x + 8.0.x + - name: Install dependencies - run: dotnet restore ${{ matrix.build-props }} - + run: dotnet restore + - name: Build - run: dotnet build --configuration Release --no-restore ${{ matrix.build-props }} - - - name: Test Examples - run: dotnet test ../examples/ ${{ matrix.build-props }} --verbosity quiet - + run: dotnet build --configuration Release --no-restore + - name: Test & Code Coverage - run: dotnet test --no-restore ${{ matrix.build-props }} --filter "Category!=E2E" --collect:"XPlat Code Coverage" --results-directory ./codecov/${{ matrix.framework }} --verbosity quiet - + run: dotnet test --no-restore --filter "Category!=E2E" --collect:"XPlat Code Coverage" --results-directory ./codecov --verbosity quiet + - name: Codecov uses: codecov/codecov-action@13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3 # 5.3.1 with: token: ${{ secrets.CODECOV_TOKEN }} - flags: ${{ matrix.framework }} fail_ci_if_error: false - name: codecov-lambda-powertools-dotnet-${{ matrix.framework }} + name: codecov-lambda-powertools-dotnet verbose: true - directory: ./libraries/codecov/${{ matrix.framework }} + directory: ./libraries/codecov \ No newline at end of file From 0f7c21d35c09ce11141499f1ec73d127b4785eae Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Sat, 22 Feb 2025 16:23:50 +0000 Subject: [PATCH 11/29] test logger --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ff2fb598..0718756e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,7 +37,7 @@ jobs: run: dotnet build --configuration Release --no-restore - name: Test & Code Coverage - run: dotnet test --no-restore --filter "Category!=E2E" --collect:"XPlat Code Coverage" --results-directory ./codecov --verbosity quiet + run: dotnet test --no-restore --filter "Category!=E2E" --collect:"XPlat Code Coverage" --results-directory ./codecov --logger "console;verbosity=quiet" - name: Codecov uses: codecov/codecov-action@13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3 # 5.3.1 From 001dabfeab8046b80f317e027408adf9c4565217 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Sat, 22 Feb 2025 17:15:21 +0000 Subject: [PATCH 12/29] fix(build): update test commands and project configurations for .NET frameworks --- .github/workflows/build.yml | 5 ++++- .../AWS.Lambda.Powertools.Metrics.AspNetCore.csproj | 6 ++++-- .../AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj | 6 +++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0718756e..b2ecc118 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,7 +37,10 @@ jobs: run: dotnet build --configuration Release --no-restore - name: Test & Code Coverage - run: dotnet test --no-restore --filter "Category!=E2E" --collect:"XPlat Code Coverage" --results-directory ./codecov --logger "console;verbosity=quiet" + run: dotnet test --no-restore --filter "Category!=E2E" --collect:"XPlat Code Coverage" --results-directory ./codecov --verbosity quiet + + - name: Test Examples + run: dotnet test ../examples/ --verbosity quiet - name: Codecov uses: codecov/codecov-action@13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3 # 5.3.1 diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/AWS.Lambda.Powertools.Metrics.AspNetCore.csproj b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/AWS.Lambda.Powertools.Metrics.AspNetCore.csproj index 529fd973..976fe8b0 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/AWS.Lambda.Powertools.Metrics.AspNetCore.csproj +++ b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/AWS.Lambda.Powertools.Metrics.AspNetCore.csproj @@ -1,11 +1,13 @@ - + + AWS.Lambda.Powertools.Metrics.AspNetCore Powertools for AWS Lambda (.NET) - Metrics AspNetCore package. AWS.Lambda.Powertools.Metrics.AspNetCore AWS.Lambda.Powertools.Metrics.AspNetCore - net8.0;net8.0 + net8.0 + false enable enable diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj index b2f6d2dc..d820a783 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj @@ -1,9 +1,10 @@ + AWS.Lambda.Powertools.Metrics.AspNetCore.Tests AWS.Lambda.Powertools.Metrics.AspNetCore.Tests - net8.0 + net8.0 enable enable @@ -26,8 +27,7 @@ - + From d6841ab8286b180dff47710f84bea3aa6f4d3005 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Sat, 22 Feb 2025 17:46:52 +0000 Subject: [PATCH 13/29] fix(build): add /tl option to dotnet build command in build.yml --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b2ecc118..f0102235 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,7 +34,7 @@ jobs: run: dotnet restore - name: Build - run: dotnet build --configuration Release --no-restore + run: dotnet build --configuration Release --no-restore /tl - name: Test & Code Coverage run: dotnet test --no-restore --filter "Category!=E2E" --collect:"XPlat Code Coverage" --results-directory ./codecov --verbosity quiet From f8f56cfb9b1f2480d762d1238fb168d8792b436c Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Sat, 22 Feb 2025 18:06:14 +0000 Subject: [PATCH 14/29] fix(metrics): add null checks and unit tests for MetricsAspect and MetricsAttribute --- .../Internal/MetricsAspect.cs | 1 + .../MetricsAttributeTests.cs | 72 +++++++++++ .../MetricsTests.cs | 121 +++++++++++++++++- 3 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsAttributeTests.cs diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs index 4ebacf14..3a09db42 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs @@ -138,6 +138,7 @@ internal static void ResetForTest() /// private static ILambdaContext GetContext(AspectEventArgs args) { + if (args == null || args.Method == null) return null; var index = Array.FindIndex(args.Method.GetParameters(), p => p.ParameterType == typeof(ILambdaContext)); if (index >= 0) { diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsAttributeTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsAttributeTests.cs new file mode 100644 index 00000000..04a1f86d --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsAttributeTests.cs @@ -0,0 +1,72 @@ +using Xunit; + +namespace AWS.Lambda.Powertools.Metrics.Tests; + +[Collection("Sequential")] +public class MetricsAttributeTests +{ + [Fact] + public void MetricsAttribute_WhenCaptureColdStartSet_ShouldSetFlag() + { + // Arrange & Act + var attribute = new MetricsAttribute + { + CaptureColdStart = true + }; + + // Assert + Assert.True(attribute.CaptureColdStart); + Assert.True(attribute.IsCaptureColdStartSet); + } + + [Fact] + public void MetricsAttribute_WhenCaptureColdStartNotSet_ShouldNotSetFlag() + { + // Arrange & Act + var attribute = new MetricsAttribute(); + + // Assert + Assert.False(attribute.CaptureColdStart); + Assert.False(attribute.IsCaptureColdStartSet); + } + + [Fact] + public void MetricsAttribute_WhenRaiseOnEmptyMetricsSet_ShouldSetFlag() + { + // Arrange & Act + var attribute = new MetricsAttribute + { + RaiseOnEmptyMetrics = true + }; + + // Assert + Assert.True(attribute.RaiseOnEmptyMetrics); + Assert.True(attribute.IsRaiseOnEmptyMetricsSet); + } + + [Fact] + public void MetricsAttribute_WhenRaiseOnEmptyMetricsNotSet_ShouldNotSetFlag() + { + // Arrange & Act + var attribute = new MetricsAttribute(); + + // Assert + Assert.False(attribute.RaiseOnEmptyMetrics); + Assert.False(attribute.IsRaiseOnEmptyMetricsSet); + } + + [Fact] + public void MetricsAttribute_ShouldSetNamespaceAndService() + { + // Arrange & Act + var attribute = new MetricsAttribute + { + Namespace = "TestNamespace", + Service = "TestService" + }; + + // Assert + Assert.Equal("TestNamespace", attribute.Namespace); + Assert.Equal("TestService", attribute.Service); + } +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs index 97aa5bf8..120d1a72 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs @@ -1,3 +1,7 @@ +using System; +using System.Collections.Generic; +using Amazon.Lambda.Core; +using Amazon.Lambda.TestUtilities; using AWS.Lambda.Powertools.Common; using NSubstitute; using Xunit; @@ -18,11 +22,11 @@ public void Metrics_Set_Execution_Environment_Context() var env = Substitute.For(); env.GetAssemblyName(Arg.Any()).Returns(assemblyName); env.GetAssemblyVersion(Arg.Any()).Returns(assemblyVersion); - + var conf = new PowertoolsConfigurations(new SystemWrapper(env)); - + var metrics = new Metrics(conf); - + // Assert env.Received(1).SetEnvironmentVariable( "AWS_EXECUTION_ENV", $"{Constants.FeatureContextIdentifier}/Metrics/{assemblyVersion}" @@ -30,4 +34,115 @@ public void Metrics_Set_Execution_Environment_Context() env.Received(1).GetEnvironmentVariable("AWS_EXECUTION_ENV"); } + + [Fact] + public void Before_With_Null_DefaultDimensions_Should_Not_Throw() + { + // Arrange + MetricsAspect.ResetForTest(); + var metricsMock = Substitute.For(); + var optionsMock = new MetricsOptions + { + CaptureColdStart = true, + DefaultDimensions = null + }; + metricsMock.Options.Returns(optionsMock); + Metrics.UseMetricsForTests(metricsMock); + + var metricsAspect = new MetricsAspect(); + var method = typeof(MetricsTests).GetMethod(nameof(TestMethod)); + var trigger = new MetricsAttribute(); + + // Act + metricsAspect.Before( + this, + "TestMethod", + new object[] { new TestLambdaContext() }, + typeof(MetricsTests), + method, + typeof(void), + new Attribute[] { trigger } + ); + + // Assert + metricsMock.Received(1).PushSingleMetric( + "ColdStart", + 1.0, + MetricUnit.Count, + Arg.Any(), + Arg.Any(), + null + ); + } + + [Fact] + public void Before_When_CaptureStartNotSet_Should_Not_Push_Metrics() + { + // Arrange + MetricsAspect.ResetForTest(); + var metricsMock = Substitute.For(); + var optionsMock = new MetricsOptions + { + CaptureColdStart = null + }; + metricsMock.Options.Returns(optionsMock); + Metrics.UseMetricsForTests(metricsMock); + + var metricsAspect = new MetricsAspect(); + var method = typeof(MetricsTests).GetMethod(nameof(TestMethod)); + var trigger = new MetricsAttribute(); + + // Act + metricsAspect.Before( + this, + "TestMethod", + new object[] { new TestLambdaContext() }, + typeof(MetricsTests), + method, + typeof(void), + new Attribute[] { trigger } + ); + + // Assert + metricsMock.DidNotReceive().PushSingleMetric( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>() + ); + } + + [Fact] + public void Before_When_RaiseOnEmptyMetricsNotSet_Should_Configure_Null() + { + // Arrange + MetricsAspect.ResetForTest(); + var method = typeof(MetricsTests).GetMethod(nameof(TestMethod)); + var trigger = new MetricsAttribute(); + + var metricsAspect = new MetricsAspect(); + + // Act + metricsAspect.Before( + this, + "TestMethod", + new object[] { new TestLambdaContext() }, + typeof(MetricsTests), + method, + typeof(void), + new Attribute[] { trigger } + ); + + // Assert + var metrics = Metrics.Instance; + Assert.False(trigger.IsRaiseOnEmptyMetricsSet); + Assert.False(metrics.Options.RaiseOnEmptyMetrics); + } + + // Helper method for the tests + internal void TestMethod(ILambdaContext context) + { + } } \ No newline at end of file From b3a57a8fd8eb7607da04bb725f21f58f3b2245a8 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Mon, 24 Feb 2025 12:25:37 +0000 Subject: [PATCH 15/29] feat(metrics): add support for default dimensions in metrics handling --- docs/core/metrics-v2.md | 65 +++++++++++++++++++ .../AWS.Lambda.Powertools.Metrics/Metrics.cs | 18 ++++- .../EMFValidationTests.cs | 26 ++++++++ .../Handlers/FunctionHandler.cs | 16 +++++ .../Handlers/FunctionHandlerTests.cs | 34 ++++++++++ .../Handlers/MetricsnBuilderHandler.cs | 10 +++ 6 files changed, 168 insertions(+), 1 deletion(-) diff --git a/docs/core/metrics-v2.md b/docs/core/metrics-v2.md index af5f6859..5502edde 100644 --- a/docs/core/metrics-v2.md +++ b/docs/core/metrics-v2.md @@ -596,6 +596,30 @@ CloudWatch EMF uses the same dimensions across all your metrics. Use **`PushSing === "Function.cs" + ```csharp hl_lines="8-13" + using AWS.Lambda.Powertools.Metrics; + + public class Function { + + [Metrics(Namespace = ExampleApplication, Service = "Booking")] + public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + Metrics.PushSingleMetric( + metricName: "ColdStart", + value: 1, + unit: MetricUnit.Count, + nameSpace: "ExampleApplication", + service: "Booking"); + ... + ``` + +By default it will skip all previously defined dimensions including default dimensions. Use default_dimensions keyword argument if you want to reuse default dimensions or specify custom dimensions from a dictionary. + +- `Metrics.DefaultDimensions`: Reuse default dimensions when using static Metrics +- `Options.DefaultDimensions`: Reuse default dimensions when using Builder or Configure patterns + +=== "New Default Dimensions.cs" + ```csharp hl_lines="8-17" using AWS.Lambda.Powertools.Metrics; @@ -616,6 +640,47 @@ CloudWatch EMF uses the same dimensions across all your metrics. Use **`PushSing }); ... ``` +=== "Default Dimensions static.cs" + + ```csharp hl_lines="8-12" + using AWS.Lambda.Powertools.Metrics; + + public class Function { + + [Metrics(Namespace = ExampleApplication, Service = "Booking")] + public async Task FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context) + { + Metrics.SetDefaultDimensions(new Dictionary + { + { "Default", "SingleMetric" } + }); + Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count, defaultDimensions: Metrics.DefaultDimensions ); + ... + ``` +=== "Default Dimensions Options / Builder patterns .cs" + + ```csharp hl_lines="9-13 18" + using AWS.Lambda.Powertools.Metrics; + + public MetricsnBuilderHandler(IMetrics metrics = null) + { + _metrics = metrics ?? new MetricsBuilder() + .WithCaptureColdStart(true) + .WithService("testService") + .WithNamespace("dotnet-powertools-test") + .WithDefaultDimensions(new Dictionary + { + { "Environment", "Prod1" }, + { "Another", "One" } + }).Build(); + } + + public void HandlerSingleMetricDimensions() + { + _metrics.PushSingleMetric("SuccessfulBooking", 1, MetricUnit.Count, defaultDimensions: _metrics.Options.DefaultDimensions); + } + ... + ``` ## AspNetCore diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs index 86823bf0..8da23030 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs @@ -17,6 +17,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using AWS.Lambda.Powertools.Common; @@ -32,12 +33,27 @@ public class Metrics : IMetrics, IDisposable /// /// Gets or sets the instance. /// - public static IMetrics Instance + internal static IMetrics Instance { get => Current.Value ?? new Metrics(PowertoolsConfigurations.Instance); private set => Current.Value = value; } + /// + /// Gets DefaultDimensions + /// + public static Dictionary DefaultDimensions => Instance.Options.DefaultDimensions; + + /// + /// Gets Namespace + /// + public static string Namespace => Instance.Options.Namespace; + + /// + /// Gets Service + /// + public static string Service => Instance.Options.Service; + /// public MetricsOptions Options => new() diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs index adce5337..04b86b3c 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs @@ -176,6 +176,32 @@ public void When_PushSingleMetric_With_Namespace() Assert.Contains("\"CloudWatchMetrics\":[{\"Namespace\":\"ExampleApplication\",\"Metrics\":[{\"Name\":\"SingleMetric\",\"Unit\":\"Count\",\"StorageResolution\":1}],\"Dimensions\":[[\"Default\"]]}]},\"Default\":\"SingleMetric\",\"SingleMetric\":1}", metricsOutput); } + [Trait("Category", "SchemaValidation")] + [Fact] + public void When_PushSingleMetric_With_No_DefaultDimensions() + { + // Act + _handler.PushSingleMetricNoDefaultDimensions(); + + var metricsOutput = _consoleOut.ToString(); + + // Assert + Assert.Contains("\"CloudWatchMetrics\":[{\"Namespace\":\"ExampleApplication\",\"Metrics\":[{\"Name\":\"SingleMetric\",\"Unit\":\"Count\"}],\"Dimensions\":[[]]}]},\"SingleMetric\":1}", metricsOutput); + } + + [Trait("Category", "SchemaValidation")] + [Fact] + public void When_PushSingleMetric_With_DefaultDimensions() + { + // Act + _handler.PushSingleMetricDefaultDimensions(); + + var metricsOutput = _consoleOut.ToString(); + + // Assert + Assert.Contains("\"CloudWatchMetrics\":[{\"Namespace\":\"ExampleApplication\",\"Metrics\":[{\"Name\":\"SingleMetric\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Default\"]]}]},\"Default\":\"SingleMetric\",\"SingleMetric\":1}", metricsOutput); + } + [Trait("Category", "SchemaValidation")] [Fact] public void When_PushSingleMetric_With_Env_Namespace() diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs index d860a9f9..8954134c 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs @@ -65,6 +65,22 @@ public void PushSingleMetricWithNamespace() }); } + [Metrics(Namespace = "ExampleApplication")] + public void PushSingleMetricNoDefaultDimensions() + { + Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count); + } + + [Metrics(Namespace = "ExampleApplication")] + public void PushSingleMetricDefaultDimensions() + { + Metrics.SetDefaultDimensions(new Dictionary + { + { "Default", "SingleMetric" } + }); + Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count, defaultDimensions: Metrics.DefaultDimensions ); + } + [Metrics] public void PushSingleMetricWithEnvNamespace() { diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs index dc338d59..1049c737 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs @@ -266,6 +266,40 @@ public void Handler_With_Builder_Should_Configure_In_Constructor_Mock() service: "testService", Arg.Any>()); metricsMock.Received(1).AddMetric("SuccessfulBooking", 1, MetricUnit.Count); } + + [Fact] + public void Handler_With_Builder_Push_Single_Metric_No_Dimensions() + { + // Arrange + var handler = new MetricsnBuilderHandler(); + + // Act + handler.HandlerSingleMetric(); + + // Get the output and parse it + var metricsOutput = _consoleOut.ToString(); + + Assert.Contains( + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"SuccessfulBooking\",\"Unit\":\"Count\"}],\"Dimensions\":[[]]}]},\"SuccessfulBooking\":1}", + metricsOutput); + } + + [Fact] + public void Handler_With_Builder_Push_Single_Metric_Dimensions() + { + // Arrange + var handler = new MetricsnBuilderHandler(); + + // Act + handler.HandlerSingleMetricDimensions(); + + // Get the output and parse it + var metricsOutput = _consoleOut.ToString(); + + Assert.Contains( + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"SuccessfulBooking\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod1\",\"Another\":\"One\",\"SuccessfulBooking\":1}", + metricsOutput); + } public void Dispose() { diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/MetricsnBuilderHandler.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/MetricsnBuilderHandler.cs index f9fd329f..82c16b9c 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/MetricsnBuilderHandler.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/MetricsnBuilderHandler.cs @@ -26,4 +26,14 @@ public void Handler(ILambdaContext context) { _metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); } + + public void HandlerSingleMetric() + { + _metrics.PushSingleMetric("SuccessfulBooking", 1, MetricUnit.Count); + } + + public void HandlerSingleMetricDimensions() + { + _metrics.PushSingleMetric("SuccessfulBooking", 1, MetricUnit.Count, defaultDimensions: _metrics.Options.DefaultDimensions); + } } \ No newline at end of file From dbb337795ebce2af9e48aa74750950cd62f55671 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Tue, 25 Feb 2025 09:53:38 +0000 Subject: [PATCH 16/29] fix functionname dimension to only coldstart --- .../Internal/MetricsAspect.cs | 24 ++++++++++----- .../Handlers/FunctionHandler.cs | 6 ++++ .../Handlers/FunctionHandlerTests.cs | 30 +++++++++++++++++-- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs index 3a09db42..dae4c321 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs @@ -14,6 +14,7 @@ */ using System; +using System.Collections.Generic; using System.Linq; using System.Reflection; using Amazon.Lambda.Core; @@ -69,11 +70,13 @@ public void Before( var trigger = triggers.OfType().First(); - _metricsInstance ??= Metrics.Configure(options => { + _metricsInstance ??= Metrics.Configure(options => + { options.Namespace = trigger.Namespace; options.Service = trigger.Service; options.RaiseOnEmptyMetrics = trigger.IsRaiseOnEmptyMetricsSet ? trigger.RaiseOnEmptyMetrics : null; options.CaptureColdStart = trigger.IsCaptureColdStartSet ? trigger.CaptureColdStart : null; + options.FunctionName = trigger.FunctionName; }); var eventArgs = new AspectEventArgs @@ -87,17 +90,22 @@ public void Before( Triggers = triggers }; - if (_metricsInstance.Options.CaptureColdStart != null && _metricsInstance.Options.CaptureColdStart.Value && _isColdStart) + if (_metricsInstance.Options.CaptureColdStart != null && _metricsInstance.Options.CaptureColdStart.Value && + _isColdStart) { - var defaultDimensions = _metricsInstance.Options?.DefaultDimensions; _isColdStart = false; - var context = GetContext(eventArgs); - - if (context is not null) + var functionName = _metricsInstance.Options?.FunctionName; + var defaultDimensions = _metricsInstance.Options?.DefaultDimensions; + + if (string.IsNullOrWhiteSpace(functionName)) + { + functionName = GetContext(eventArgs)?.FunctionName ?? ""; + } + + if (!string.IsNullOrWhiteSpace(functionName)) { - defaultDimensions?.Add("FunctionName", context.FunctionName); - _metricsInstance.SetDefaultDimensions(defaultDimensions); + defaultDimensions?.Add("FunctionName", functionName); } _metricsInstance.PushSingleMetric( diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs index 8954134c..4abb6f45 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs @@ -230,4 +230,10 @@ public void HandleWithParamAndLambdaContext(string input, ILambdaContext context { } + + [Metrics(Namespace = "ns", Service = "svc", CaptureColdStart = true)] + public void HandleOnlyDimensionsInColdStart(ILambdaContext context) + { + Metrics.AddMetric("MyMetric", 1); + } } \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs index 1049c737..44d4e841 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs @@ -174,7 +174,7 @@ public void DefaultDimensions_AreAppliedCorrectly_WithContext_FunctionName() metricsOutput); // Assert successful Memory metrics Assert.Contains( - "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"Memory\",\"Unit\":\"Megabytes\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\",\"FunctionName\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod\",\"Another\":\"One\",\"FunctionName\":\"My_Function_Name\",\"Memory\":10}", + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"Memory\",\"Unit\":\"Megabytes\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod\",\"Another\":\"One\",\"Memory\":10}", metricsOutput); } @@ -231,7 +231,7 @@ public void Handler_With_Builder_Should_Configure_In_Constructor() metricsOutput); // Assert successful Memory metrics Assert.Contains( - "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"SuccessfulBooking\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\",\"FunctionName\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod1\",\"Another\":\"One\",\"FunctionName\":\"My_Function_Name\",\"SuccessfulBooking\":1}", + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"SuccessfulBooking\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod1\",\"Another\":\"One\",\"SuccessfulBooking\":1}", metricsOutput); } @@ -300,6 +300,32 @@ public void Handler_With_Builder_Push_Single_Metric_Dimensions() "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"SuccessfulBooking\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod1\",\"Another\":\"One\",\"SuccessfulBooking\":1}", metricsOutput); } + + [Fact] + public void Dimension_Only_Set_In_Cold_Start() + { + // Arrange + var handler = new FunctionHandler(); + + // Act + handler.HandleOnlyDimensionsInColdStart(new TestLambdaContext + { + FunctionName = "My_Function_Name" + }); + + // Get the output and parse it + var metricsOutput = _consoleOut.ToString(); + + // Assert cold start + Assert.Contains( + "\"CloudWatchMetrics\":[{\"Namespace\":\"ns\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"FunctionName\"]]}]},\"Service\":\"svc\",\"FunctionName\":\"My_Function_Name\",\"ColdStart\":1}", + metricsOutput); + + // Assert successful add metric without dimensions + Assert.Contains( + "\"CloudWatchMetrics\":[{\"Namespace\":\"ns\",\"Metrics\":[{\"Name\":\"MyMetric\",\"Unit\":\"None\"}],\"Dimensions\":[[\"Service\"]]}]},\"Service\":\"svc\",\"MyMetric\":1}", + metricsOutput); + } public void Dispose() { From 9e71bec12e51829d7c57863993860bcd1dcd21a7 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Tue, 25 Feb 2025 10:04:09 +0000 Subject: [PATCH 17/29] remove functionname --- .../Internal/MetricsAspect.cs | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs index dae4c321..1577f5fc 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs @@ -76,7 +76,6 @@ public void Before( options.Service = trigger.Service; options.RaiseOnEmptyMetrics = trigger.IsRaiseOnEmptyMetricsSet ? trigger.RaiseOnEmptyMetrics : null; options.CaptureColdStart = trigger.IsCaptureColdStartSet ? trigger.CaptureColdStart : null; - options.FunctionName = trigger.FunctionName; }); var eventArgs = new AspectEventArgs @@ -90,22 +89,16 @@ public void Before( Triggers = triggers }; - if (_metricsInstance.Options.CaptureColdStart != null && _metricsInstance.Options.CaptureColdStart.Value && - _isColdStart) + if (_metricsInstance.Options.CaptureColdStart != null && _metricsInstance.Options.CaptureColdStart.Value && _isColdStart) { - _isColdStart = false; - - var functionName = _metricsInstance.Options?.FunctionName; var defaultDimensions = _metricsInstance.Options?.DefaultDimensions; + _isColdStart = false; - if (string.IsNullOrWhiteSpace(functionName)) - { - functionName = GetContext(eventArgs)?.FunctionName ?? ""; - } - - if (!string.IsNullOrWhiteSpace(functionName)) + var context = GetContext(eventArgs); + + if (context is not null) { - defaultDimensions?.Add("FunctionName", functionName); + defaultDimensions?.Add("FunctionName", context.FunctionName); } _metricsInstance.PushSingleMetric( From 4ea9fa9ec90447b73e548845a1c05c8b7dc31516 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Tue, 25 Feb 2025 16:27:20 +0000 Subject: [PATCH 18/29] fix(metrics): rename variable for default dimensions in cold start handling --- .../AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs index 1577f5fc..d5eac029 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs @@ -91,14 +91,14 @@ public void Before( if (_metricsInstance.Options.CaptureColdStart != null && _metricsInstance.Options.CaptureColdStart.Value && _isColdStart) { - var defaultDimensions = _metricsInstance.Options?.DefaultDimensions; + var dimensions = _metricsInstance.Options?.DefaultDimensions; _isColdStart = false; var context = GetContext(eventArgs); if (context is not null) { - defaultDimensions?.Add("FunctionName", context.FunctionName); + dimensions?.Add("FunctionName", context.FunctionName); } _metricsInstance.PushSingleMetric( @@ -107,7 +107,7 @@ public void Before( MetricUnit.Count, _metricsInstance.Options?.Namespace ?? "", _metricsInstance.Options?.Service ?? "", - defaultDimensions + dimensions ); } } From 5bc9af2137b259e13f3fb119978514920ee170cb Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Wed, 26 Feb 2025 10:19:22 +0000 Subject: [PATCH 19/29] refactor(metrics): simplify MetricsTests by removing unused variables and improving syntax --- .../MetricsTests.cs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs index ea87558c..6d82cc92 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs @@ -2,8 +2,6 @@ using System.Collections.Generic; using Amazon.Lambda.Core; using Amazon.Lambda.TestUtilities; -using System; -using System.Collections.Generic; using AWS.Lambda.Powertools.Common; using NSubstitute; using Xunit; @@ -27,7 +25,7 @@ public void Metrics_Set_Execution_Environment_Context() var conf = new PowertoolsConfigurations(new SystemWrapper(env)); - var metrics = new Metrics(conf); + _ = new Metrics(conf); // Assert env.Received(1).SetEnvironmentVariable( @@ -59,11 +57,11 @@ public void Before_With_Null_DefaultDimensions_Should_Not_Throw() metricsAspect.Before( this, "TestMethod", - new object[] { new TestLambdaContext() }, + [new TestLambdaContext()], typeof(MetricsTests), method, typeof(void), - new Attribute[] { trigger } + [trigger] ); // Assert @@ -72,8 +70,7 @@ public void Before_With_Null_DefaultDimensions_Should_Not_Throw() 1.0, MetricUnit.Count, Arg.Any(), - Arg.Any(), - null + Arg.Any() ); } @@ -152,7 +149,7 @@ internal void TestMethod(ILambdaContext context) public void When_Constructor_With_Namespace_And_Service_Should_Set_Both() { // Arrange - var metricsMock = Substitute.For(); + Substitute.For(); var powertoolsConfigMock = Substitute.For(); // Act @@ -167,13 +164,13 @@ public void When_Constructor_With_Namespace_And_Service_Should_Set_Both() public void When_Constructor_With_Null_Namespace_And_Service_Should_Not_Set() { // Arrange - var metricsMock = Substitute.For(); + Substitute.For(); var powertoolsConfigMock = Substitute.For(); powertoolsConfigMock.MetricsNamespace.Returns((string)null); powertoolsConfigMock.Service.Returns("service_undefined"); // Act - var metrics = new Metrics(powertoolsConfigMock, null, null); + var metrics = new Metrics(powertoolsConfigMock); // Assert Assert.Null(metrics.GetNamespace()); @@ -184,7 +181,7 @@ public void When_Constructor_With_Null_Namespace_And_Service_Should_Not_Set() public void When_AddMetric_With_EmptyKey_Should_ThrowArgumentNullException() { // Arrange - var metricsMock = Substitute.For(); + Substitute.For(); var powertoolsConfigMock = Substitute.For(); IMetrics metrics = new Metrics(powertoolsConfigMock); From 3444d88ee1c1f9fcd1f6422c67c14039d57c3a92 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Wed, 26 Feb 2025 10:28:10 +0000 Subject: [PATCH 20/29] docs(metrics): document breaking changes in metrics output format and default dimensions --- docs/core/metrics-v2.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/core/metrics-v2.md b/docs/core/metrics-v2.md index af5f6859..7c31580d 100644 --- a/docs/core/metrics-v2.md +++ b/docs/core/metrics-v2.md @@ -16,6 +16,11 @@ These metrics can be visualized through [Amazon CloudWatch Console](https://aws. * Ahead-of-Time compilation to native code support [AOT](https://docs.aws.amazon.com/lambda/latest/dg/dotnet-native-aot.html) from version 1.7.0 * Support for AspNetCore middleware and filters to capture metrics for HTTP requests +## Breaking changes from V1 + +* **`Dimensions`** outputs as an array of arrays instead of an array of objects. Example: `Dimensions: [["service", "Environment"]]` instead of `Dimensions: ["service", "Environment"]` +* **`FunctionName`** is not added as default dimension and only to cold start metric. +
From 3b090f76d38e8960c734a9db3dccffc8bbd1e754 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:38:40 +0000 Subject: [PATCH 21/29] feat(metrics): implement IConsoleWrapper for abstracting console operations and enhance cold start metric capturing --- .../Core/ConsoleWrapper.cs | 31 ++++ .../Core/IConsoleWrapper.cs | 46 +++++ .../Http/MetricsHelper.cs | 19 +- .../AWS.Lambda.Powertools.Metrics/IMetrics.cs | 7 + .../Internal/MetricsAspect.cs | 22 +-- .../InternalsVisibleTo.cs | 4 +- .../AWS.Lambda.Powertools.Metrics/Metrics.cs | 91 ++++++---- ...Powertools.Metrics.AspNetCore.Tests.csproj | 2 + .../MetricsEndpointExtensionsTests.cs | 164 ++++++++++++++++++ .../MetricsFilterTests.cs | 52 +----- .../MetricsHelperTests.cs | 84 --------- .../MetricsMiddlewareExtensionsTests.cs | 99 ----------- .../Handlers/FunctionHandlerTests.cs | 94 +--------- .../MetricsTests.cs | 139 +++++++++------ libraries/tests/Directory.Packages.props | 1 + 15 files changed, 417 insertions(+), 438 deletions(-) create mode 100644 libraries/src/AWS.Lambda.Powertools.Common/Core/ConsoleWrapper.cs create mode 100644 libraries/src/AWS.Lambda.Powertools.Common/Core/IConsoleWrapper.cs create mode 100644 libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsEndpointExtensionsTests.cs delete mode 100644 libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsHelperTests.cs delete mode 100644 libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsMiddlewareExtensionsTests.cs diff --git a/libraries/src/AWS.Lambda.Powertools.Common/Core/ConsoleWrapper.cs b/libraries/src/AWS.Lambda.Powertools.Common/Core/ConsoleWrapper.cs new file mode 100644 index 00000000..87321140 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Common/Core/ConsoleWrapper.cs @@ -0,0 +1,31 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System; + +namespace AWS.Lambda.Powertools.Common; + +/// +public class ConsoleWrapper : IConsoleWrapper +{ + /// + public void WriteLine(string message) => Console.WriteLine(message); + /// + public void Debug(string message) => System.Diagnostics.Debug.WriteLine(message); + /// + public void Error(string message) => Console.Error.WriteLine(message); + /// + public string ReadLine() => Console.ReadLine(); +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Common/Core/IConsoleWrapper.cs b/libraries/src/AWS.Lambda.Powertools.Common/Core/IConsoleWrapper.cs new file mode 100644 index 00000000..de75020e --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Common/Core/IConsoleWrapper.cs @@ -0,0 +1,46 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +namespace AWS.Lambda.Powertools.Common; + +/// +/// Wrapper for console operations to facilitate testing by abstracting system console interactions. +/// +public interface IConsoleWrapper +{ + /// + /// Writes the specified message followed by a line terminator to the standard output stream. + /// + /// The message to write. + void WriteLine(string message); + + /// + /// Writes a debug message to the trace listeners in the Debug.Listeners collection. + /// + /// The debug message to write. + void Debug(string message); + + /// + /// Writes the specified error message followed by a line terminator to the standard error stream. + /// + /// The error message to write. + void Error(string message); + + /// + /// Reads the next line of characters from the standard input stream. + /// + /// The next line of characters from the input stream, or null if no more lines are available. + string ReadLine(); +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsHelper.cs b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsHelper.cs index 250caef8..a86ff0b0 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsHelper.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsHelper.cs @@ -43,29 +43,16 @@ public MetricsHelper(IMetrics metrics) /// A task that represents the asynchronous operation. public Task CaptureColdStartMetrics(HttpContext context) { - if (_metrics.Options.CaptureColdStart == null || !_metrics.Options.CaptureColdStart.Value || !_isColdStart) + if (!_isColdStart) return Task.CompletedTask; - var defaultDimensions = _metrics.Options.DefaultDimensions; lock (_metrics) { _isColdStart = false; } - if (context.Items["LambdaContext"] is ILambdaContext lambdaContext) - { - defaultDimensions?.Add("FunctionName", lambdaContext.FunctionName); - _metrics.SetDefaultDimensions(defaultDimensions); - } - - _metrics.PushSingleMetric( - "ColdStart", - 1.0, - MetricUnit.Count, - _metrics.Options.Namespace, - _metrics.Options.Service, - defaultDimensions - ); + _metrics.CaptureColdStartMetric(context.Items["LambdaContext"] as ILambdaContext); + return Task.CompletedTask; } diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs index 69ef2ee3..61e15b7b 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs @@ -14,6 +14,7 @@ */ using System.Collections.Generic; +using Amazon.Lambda.Core; namespace AWS.Lambda.Powertools.Metrics; @@ -106,4 +107,10 @@ void PushSingleMetric(string name, double value, MetricUnit unit, string nameSpa /// /// The metrics options. public MetricsOptions Options { get; } + + /// + /// Captures the cold start metric. + /// + /// + void CaptureColdStartMetric(ILambdaContext context); } \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs index 6823b697..aad3617c 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs @@ -88,28 +88,10 @@ public void Before( Triggers = triggers }; - if (_metricsInstance.Options.CaptureColdStart != null && _metricsInstance.Options.CaptureColdStart.Value && _isColdStart) + if (_isColdStart) { - var defaultDimensions = _metricsInstance.Options?.DefaultDimensions; + _metricsInstance.CaptureColdStartMetric(GetContext(eventArgs)); _isColdStart = false; - - var context = GetContext(eventArgs); - - if (context is not null) - { - defaultDimensions ??= new Dictionary(); - defaultDimensions.Add("FunctionName", context.FunctionName); - _metricsInstance.SetDefaultDimensions(defaultDimensions); - } - - _metricsInstance.PushSingleMetric( - "ColdStart", - 1.0, - MetricUnit.Count, - _metricsInstance.Options?.Namespace ?? "", - _metricsInstance.Options?.Service ?? "", - defaultDimensions - ); } } diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/InternalsVisibleTo.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/InternalsVisibleTo.cs index 0e44da1c..a1b53257 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/InternalsVisibleTo.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/InternalsVisibleTo.cs @@ -15,4 +15,6 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("AWS.Lambda.Powertools.Metrics.Tests")] \ No newline at end of file +[assembly: InternalsVisibleTo("AWS.Lambda.Powertools.Metrics.Tests")] +[assembly: InternalsVisibleTo("AWS.Lambda.Powertools.Metrics.AspNetCore")] +[assembly: InternalsVisibleTo("AWS.Lambda.Powertools.Metrics.AspNetCore.Tests")] \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs index 86823bf0..06b1dd18 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs @@ -15,9 +15,8 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; -using System.Threading; +using Amazon.Lambda.Core; using AWS.Lambda.Powertools.Common; namespace AWS.Lambda.Powertools.Metrics; @@ -34,12 +33,12 @@ public class Metrics : IMetrics, IDisposable /// public static IMetrics Instance { - get => Current.Value ?? new Metrics(PowertoolsConfigurations.Instance); - private set => Current.Value = value; + get => _instance ?? new Metrics(PowertoolsConfigurations.Instance, consoleWrapper: new ConsoleWrapper()); + private set => _instance = value; } /// - public MetricsOptions Options => + public MetricsOptions Options => _options ?? new() { CaptureColdStart = _captureColdStartEnabled, @@ -52,7 +51,7 @@ public static IMetrics Instance /// /// The instance /// - private static readonly AsyncLocal Current = new(); + private static IMetrics _instance; /// /// The context @@ -74,11 +73,20 @@ public static IMetrics Instance /// private bool _captureColdStartEnabled; - // - // Shared synchronization object - // + /// + /// Shared synchronization object + /// private readonly object _lockObj = new(); + /// + /// The options + /// + private readonly MetricsOptions _options; + + /// + /// The console wrapper for console output + /// + private readonly IConsoleWrapper _consoleWrapper; /// /// Initializes a new instance of the class. @@ -117,13 +125,17 @@ public static IMetrics Configure(Action configure) /// Metrics Service Name /// Instructs metrics validation to throw exception if no metrics are provided /// Instructs metrics capturing the ColdStart is enabled + /// For console output + /// MetricsOptions internal Metrics(IPowertoolsConfigurations powertoolsConfigurations, string nameSpace = null, string service = null, - bool raiseOnEmptyMetrics = false, bool captureColdStartEnabled = false) + bool raiseOnEmptyMetrics = false, bool captureColdStartEnabled = false, IConsoleWrapper consoleWrapper = null, MetricsOptions options = null) { _powertoolsConfigurations = powertoolsConfigurations; + _consoleWrapper = consoleWrapper; _context = new MetricsContext(); _raiseOnEmptyMetrics = raiseOnEmptyMetrics; _captureColdStartEnabled = captureColdStartEnabled; + _options = options; Instance = this; _powertoolsConfigurations.SetExecutionEnvironment(this); @@ -165,7 +177,7 @@ void IMetrics.AddMetric(string key, double value, MetricUnit unit, MetricResolut } else { - Debug.WriteLine( + _consoleWrapper.Debug( $"##WARNING##: Metrics should be initialized in Handler method before calling {nameof(AddMetric)} method."); } } @@ -237,7 +249,7 @@ void IMetrics.Flush(bool metricsOverflow) { var emfPayload = _context.Serialize(); - Console.WriteLine(emfPayload); + _consoleWrapper.WriteLine(emfPayload); _context.ClearMetrics(); @@ -246,7 +258,7 @@ void IMetrics.Flush(bool metricsOverflow) else { if (!_captureColdStartEnabled) - Console.WriteLine( + _consoleWrapper.WriteLine( "##User-WARNING## No application metrics to publish. The cold-start metric may be published if enabled. If application metrics should never be empty, consider using 'RaiseOnEmptyMetrics = true'"); } } @@ -411,15 +423,7 @@ public static void SetDefaultDimensions(Dictionary defaultDimens /// public static void ClearDefaultDimensions() { - if (Instance != null) - { - Instance.ClearDefaultDimensions(); - } - else - { - Debug.WriteLine( - $"##WARNING##: Metrics should be initialized in Handler method before calling {nameof(ClearDefaultDimensions)} method."); - } + Instance.ClearDefaultDimensions(); } /// @@ -431,7 +435,7 @@ private void Flush(MetricsContext context) { var emfPayload = context.Serialize(); - Console.WriteLine(emfPayload); + _consoleWrapper.WriteLine(emfPayload); } /// @@ -449,16 +453,8 @@ public static void PushSingleMetric(string name, double value, MetricUnit unit, string service = null, Dictionary defaultDimensions = null, MetricResolution resolution = MetricResolution.Default) { - if (Instance != null) - { - Instance.PushSingleMetric(name, value, unit, nameSpace, service, defaultDimensions, - resolution); - } - else - { - Debug.WriteLine( - $"##WARNING##: Metrics should be initialized in Handler method before calling {nameof(PushSingleMetric)} method."); - } + Instance.PushSingleMetric(name, value, unit, nameSpace, service, defaultDimensions, + resolution); } /// @@ -487,10 +483,37 @@ private Dictionary ListToDictionary(List dimension } catch (Exception e) { - Debug.WriteLine("Error converting list to dictionary: " + e.Message); + _consoleWrapper.Debug("Error converting list to dictionary: " + e.Message); return dictionary; } } + + /// + /// Captures the cold start metric. + /// + /// The ILambdaContext. + void IMetrics.CaptureColdStartMetric(ILambdaContext context) + { + if (Options.CaptureColdStart == null || !Options.CaptureColdStart.Value) return; + + // bring default dimensions if exist + var dimensions = Options?.DefaultDimensions; + + if (context is not null) + { + dimensions ??= new Dictionary(); + dimensions.Add("FunctionName", context.FunctionName); + } + + PushSingleMetric( + "ColdStart", + 1.0, + MetricUnit.Count, + Options?.Namespace ?? "", + Options?.Service ?? "", + dimensions + ); + } /// /// Helper method for testing purposes. Clears static instance between test execution diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj index d820a783..15ac1312 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests.csproj @@ -13,6 +13,8 @@ + + diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsEndpointExtensionsTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsEndpointExtensionsTests.cs new file mode 100644 index 00000000..155d6e29 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsEndpointExtensionsTests.cs @@ -0,0 +1,164 @@ +using Amazon.Lambda.TestUtilities; +using AWS.Lambda.Powertools.Common; +using AWS.Lambda.Powertools.Metrics.AspNetCore.Http; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Xunit; + +namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Tests; + +public class MetricsEndpointExtensionsTests : IDisposable +{ + [Fact] + public async Task When_WithMetrics_Should_Add_ColdStart() + { + // Arrange + var options = new MetricsOptions + { + CaptureColdStart = true, + Namespace = "TestNamespace", + Service = "TestService" + }; + + var conf = Substitute.For(); + var consoleWrapper = Substitute.For(); + var metrics = new Metrics(conf, consoleWrapper: consoleWrapper, options: options); + + var builder = WebApplication.CreateBuilder(); + builder.Services.AddSingleton(metrics); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + + app.MapGet("/test", () => Results.Ok(new { success = true })).WithMetrics(); + + await app.StartAsync(); + var client = app.GetTestClient(); + + // Act + var response = await client.GetAsync("/test"); + + // Assert + Assert.Equal(200, (int)response.StatusCode); + + // Assert metrics calls + consoleWrapper.Received(1).WriteLine( + Arg.Is(s => s.Contains("CloudWatchMetrics\":[{\"Namespace\":\"TestNamespace\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[]]}]},\"ColdStart\":1}")) + ); + + + await app.StopAsync(); + } + + [Fact] + public async Task When_WithMetrics_Should_Add_ColdStart_Dimensions() + { + // Arrange + var options = new MetricsOptions + { + CaptureColdStart = true, + Namespace = "TestNamespace", + Service = "TestService" + }; + + var conf = Substitute.For(); + var consoleWrapper = Substitute.For(); + var metrics = new Metrics(conf, consoleWrapper: consoleWrapper, options: options); + + var builder = WebApplication.CreateBuilder(); + builder.Services.AddSingleton(metrics); + builder.WebHost.UseTestServer(); + + + var app = builder.Build(); + app.Use(async (context, next) => + { + var lambdaContext = new TestLambdaContext + { + FunctionName = "TestFunction" + }; + context.Items["LambdaContext"] = lambdaContext; + await next(); + }); + + app.MapGet("/test", () => Results.Ok(new { success = true })).WithMetrics(); + + await app.StartAsync(); + var client = app.GetTestClient(); + + // Act + var response = await client.GetAsync("/test"); + + // Assert + Assert.Equal(200, (int)response.StatusCode); + + // Assert metrics calls + consoleWrapper.Received(1).WriteLine( + Arg.Is(s => s.Contains("CloudWatchMetrics\":[{\"Namespace\":\"TestNamespace\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"FunctionName\"]]}]},\"FunctionName\":\"TestFunction\",\"ColdStart\":1}")) + ); + + await app.StopAsync(); + } + + [Fact] + public async Task When_WithMetrics_Should_Add_ColdStart_Default_Dimensions() + { + // Arrange + var options = new MetricsOptions + { + CaptureColdStart = true, + Namespace = "TestNamespace", + Service = "TestService", + DefaultDimensions = new Dictionary + { + { "Environment", "Prod" } + } + }; + + var conf = Substitute.For(); + var consoleWrapper = Substitute.For(); + var metrics = new Metrics(conf, consoleWrapper: consoleWrapper, options: options); + + var builder = WebApplication.CreateBuilder(); + builder.Services.AddSingleton(metrics); + builder.WebHost.UseTestServer(); + + + var app = builder.Build(); + app.Use(async (context, next) => + { + var lambdaContext = new TestLambdaContext + { + FunctionName = "TestFunction" + }; + context.Items["LambdaContext"] = lambdaContext; + await next(); + }); + + app.MapGet("/test", () => Results.Ok(new { success = true })).WithMetrics(); + + await app.StartAsync(); + var client = app.GetTestClient(); + + // Act + var response = await client.GetAsync("/test"); + + // Assert + Assert.Equal(200, (int)response.StatusCode); + + // Assert metrics calls + consoleWrapper.Received(1).WriteLine( + Arg.Is(s => s.Contains("CloudWatchMetrics\":[{\"Namespace\":\"TestNamespace\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Environment\",\"FunctionName\"]]}]},\"Environment\":\"Prod\",\"FunctionName\":\"TestFunction\",\"ColdStart\":1}")) + ); + + await app.StopAsync(); + } + + public void Dispose() + { + MetricsHelper.ResetColdStart(); + } +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsFilterTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsFilterTests.cs index 6a0df634..d4dad622 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsFilterTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsFilterTests.cs @@ -6,7 +6,7 @@ namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Tests; -public class MetricsFilterTests +public class MetricsFilterTests : IDisposable { private readonly IMetrics _metrics; private readonly EndpointFilterInvocationContext _context; @@ -25,40 +25,7 @@ public MetricsFilterTests() } [Fact] - public async Task InvokeAsync_WhenColdStartEnabled_RecordsColdStartMetric() - { - // Arrange - var options = new MetricsOptions - { - CaptureColdStart = true, - Namespace = "TestNamespace", - Service = "TestService", - DefaultDimensions = new Dictionary() - }; - - _metrics.Options.Returns(options); - _lambdaContext.FunctionName.Returns("TestFunction"); - - var filter = new MetricsFilter(_metrics); - var next = new EndpointFilterDelegate(_ => ValueTask.FromResult("result")); - - // Act - var result = await filter.InvokeAsync(_context, next); - - // Assert - _metrics.Received(1).PushSingleMetric( - "ColdStart", - 1.0, - MetricUnit.Count, - "TestNamespace", - "TestService", - Arg.Any>() - ); - Assert.Equal("result", result); - } - - [Fact] - public async Task InvokeAsync_WhenColdStartDisabled_DoesNotRecordMetric() + public async Task InvokeAsync_Second_Call_DoesNotRecord_ColdStart_Metric() { // Arrange var options = new MetricsOptions { CaptureColdStart = false }; @@ -68,17 +35,11 @@ public async Task InvokeAsync_WhenColdStartDisabled_DoesNotRecordMetric() var next = new EndpointFilterDelegate(_ => ValueTask.FromResult("result")); // Act + _ = await filter.InvokeAsync(_context, next); var result = await filter.InvokeAsync(_context, next); // Assert - _metrics.DidNotReceive().PushSingleMetric( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any>() - ); + _metrics.Received(1).CaptureColdStartMetric(Arg.Any() ); Assert.Equal("result", result); } @@ -107,4 +68,9 @@ public async Task InvokeAsync_ShouldCallNextAndContinue() Assert.True(called); Assert.Equal("result", result); } + + public void Dispose() + { + MetricsHelper.ResetColdStart(); + } } \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsHelperTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsHelperTests.cs deleted file mode 100644 index 37064d24..00000000 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsHelperTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System.Reflection; -using AWS.Lambda.Powertools.Metrics.AspNetCore.Http; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.Routing.Patterns; -using NSubstitute; -using Xunit; - -namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Tests; - -public class MetricsHelperTests -{ - [Fact] - public async Task CaptureColdStartMetrics_WhenEnabled_ShouldPushMetric() - { - // Arrange - var metrics = Substitute.For(); - metrics.Options.Returns(new MetricsOptions - { - CaptureColdStart = true, - Namespace = "TestNamespace", - Service = "TestService" - }); - - var context = new DefaultHttpContext(); - var helper = new MetricsHelper(metrics); - - // Act - await helper.CaptureColdStartMetrics(context); - - // Assert - metrics.Received(1).PushSingleMetric( - Arg.Is(s => s == "ColdStart"), - Arg.Is(d => d == 1.0), - Arg.Is(u => u == MetricUnit.Count), - Arg.Is(s => s == "TestNamespace"), - Arg.Is(s => s == "TestService"), - Arg.Any>() - ); - } - - [Fact] - public async Task CaptureColdStartMetrics_WhenDisabled_ShouldNotPushMetric() - { - // Arrange - var metrics = Substitute.For(); - metrics.Options.Returns(new MetricsOptions { CaptureColdStart = false }); - - var context = new DefaultHttpContext(); - var helper = new MetricsHelper(metrics); - - // Act - await helper.CaptureColdStartMetrics(context); - - // Assert - metrics.DidNotReceive().PushSingleMetric( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any>() - ); - } -} - -public static class EndpointFilterInvocationContextHelper -{ - public static EndpointFilterInvocationContext Create(HttpContext httpContext, object[] arguments) - { - var endpoint = new RouteEndpoint( - c => Task.CompletedTask, - RoutePatternFactory.Parse("/"), - 0, - EndpointMetadataCollection.Empty, - "test"); - - var constructor = typeof(EndpointFilterInvocationContext) - .GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance) - .First(); - - return (EndpointFilterInvocationContext)constructor.Invoke(new object[] { httpContext, endpoint, arguments }); - } -} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsMiddlewareExtensionsTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsMiddlewareExtensionsTests.cs deleted file mode 100644 index 3d5a9892..00000000 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsMiddlewareExtensionsTests.cs +++ /dev/null @@ -1,99 +0,0 @@ -using AWS.Lambda.Powertools.Metrics.AspNetCore.Http; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using NSubstitute; -using Xunit; - -namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Tests; - -public class MetricsMiddlewareExtensionsTests : IDisposable -{ - public MetricsMiddlewareExtensionsTests() - { - MetricsHelper.ResetColdStart(); - } - - public void Dispose() - { - MetricsHelper.ResetColdStart(); - } - - [Fact] - public async Task UseMetrics_ShouldCaptureColdStart_WhenEnabled() - { - // Arrange - var metrics = Substitute.For(); - metrics.Options.Returns(new MetricsOptions - { - CaptureColdStart = true, - Namespace = "TestNamespace", - Service = "TestService" - }); - - var services = new ServiceCollection(); - services.AddSingleton(metrics); - var serviceProvider = services.BuildServiceProvider(); - - var context = new DefaultHttpContext - { - RequestServices = serviceProvider - }; - - var appBuilder = new ApplicationBuilder(serviceProvider); - appBuilder.UseMetrics(); - var app = appBuilder.Build(); - - // Act - await app.Invoke(context); - - // Assert - metrics.Received(1).PushSingleMetric( - Arg.Is(s => s == "ColdStart"), - Arg.Is(d => d == 1.0), - Arg.Is(u => u == MetricUnit.Count), - Arg.Is(s => s == "TestNamespace"), - Arg.Is(s => s == "TestService"), - Arg.Any>() - ); - } - - [Fact] - public async Task UseMetrics_ShouldNotCaptureColdStart_WhenDisabled() - { - // Arrange - var metrics = Substitute.For(); - metrics.Options.Returns(new MetricsOptions - { - CaptureColdStart = false, - Namespace = "TestNamespace", - Service = "TestService" - }); - - var services = new ServiceCollection(); - services.AddSingleton(metrics); - var serviceProvider = services.BuildServiceProvider(); - - var context = new DefaultHttpContext - { - RequestServices = serviceProvider - }; - - var appBuilder = new ApplicationBuilder(serviceProvider); - appBuilder.UseMetrics(); - var app = appBuilder.Build(); - - // Act - await app.Invoke(context); - - // Assert - metrics.DidNotReceive().PushSingleMetric( - Arg.Is(s => s == "ColdStart"), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any>() - ); - } -} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs index f09dc6af..1f89a208 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs @@ -16,6 +16,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Amazon.Lambda.Core; using Amazon.Lambda.TestUtilities; using AWS.Lambda.Powertools.Common; using NSubstitute; @@ -171,7 +172,7 @@ public void DefaultDimensions_AreAppliedCorrectly_WithContext_FunctionName() metricsOutput); // Assert successful Memory metrics Assert.Contains( - "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"Memory\",\"Unit\":\"Megabytes\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\",\"FunctionName\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod\",\"Another\":\"One\",\"FunctionName\":\"My_Function_Name\",\"Memory\":10}", + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"Memory\",\"Unit\":\"Megabytes\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod\",\"Another\":\"One\",\"Memory\":10}", metricsOutput); } @@ -202,8 +203,7 @@ public void Handler_WithMockedMetrics_ShouldCallAddMetric() sut.Handler(); // Assert - metricsMock.Received(1).PushSingleMetric("ColdStart", 1, MetricUnit.Count, "dotnet-powertools-test", - service: "testService", Arg.Any>()); + metricsMock.Received(1).CaptureColdStartMetric(Arg.Any()); metricsMock.Received(1).AddMetric("SuccessfulBooking", 1, MetricUnit.Count); } @@ -228,7 +228,7 @@ public void Handler_With_Builder_Should_Configure_In_Constructor() metricsOutput); // Assert successful Memory metrics Assert.Contains( - "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"SuccessfulBooking\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\",\"FunctionName\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod1\",\"Another\":\"One\",\"FunctionName\":\"My_Function_Name\",\"SuccessfulBooking\":1}", + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"SuccessfulBooking\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod1\",\"Another\":\"One\",\"SuccessfulBooking\":1}", metricsOutput); } @@ -259,8 +259,7 @@ public void Handler_With_Builder_Should_Configure_In_Constructor_Mock() FunctionName = "My_Function_Name" }); - metricsMock.Received(1).PushSingleMetric("ColdStart", 1, MetricUnit.Count, "dotnet-powertools-test", - service: "testService", Arg.Any>()); + metricsMock.Received(1).CaptureColdStartMetric(Arg.Any()); metricsMock.Received(1).AddMetric("SuccessfulBooking", 1, MetricUnit.Count); } @@ -282,89 +281,6 @@ public void Handler_With_Builder_Should_Raise_Empty_Metrics() var exception = Assert.Throws(() => handler.HandlerEmpty()); Assert.Equal("No metrics have been provided.", exception.Message); } - - [Fact] - public void When_ColdStart_Should_Use_DefaultDimensions_From_Options() - { - // Arrange - var metricsMock = Substitute.For(); - var expectedDimensions = new Dictionary - { - { "Environment", "Test" }, - { "Region", "us-east-1" } - }; - - metricsMock.Options.Returns(new MetricsOptions - { - Namespace = "dotnet-powertools-test", - Service = "testService", - CaptureColdStart = true, - DefaultDimensions = expectedDimensions - }); - - Metrics.UseMetricsForTests(metricsMock); - - var context = new TestLambdaContext - { - FunctionName = "TestFunction" - }; - - // Act - _handler.HandleWithLambdaContext(context); - - // Assert - metricsMock.Received(1).PushSingleMetric( - "ColdStart", - 1.0, - MetricUnit.Count, - "dotnet-powertools-test", - "testService", - Arg.Is>(d => - d.ContainsKey("Environment") && d["Environment"] == "Test" && - d.ContainsKey("Region") && d["Region"] == "us-east-1" && - d.ContainsKey("FunctionName") && d["FunctionName"] == "TestFunction" - ) - ); - } - - [Fact] - public void When_ColdStart_And_DefaultDimensions_Is_Null_Should_Only_Add_Service_And_FunctionName() - { - // Arrange - var metricsMock = Substitute.For(); - - metricsMock.Options.Returns(new MetricsOptions - { - Namespace = "dotnet-powertools-test", - Service = "testService", - CaptureColdStart = true, - DefaultDimensions = null - }); - - Metrics.UseMetricsForTests(metricsMock); - - var context = new TestLambdaContext - { - FunctionName = "TestFunction" - }; - - // Act - _handler.HandleWithLambdaContext(context); - - // Assert - metricsMock.Received(1).PushSingleMetric( - "ColdStart", - 1.0, - MetricUnit.Count, - "dotnet-powertools-test", - "testService", - Arg.Is>(d => - d.Count == 1 && - d.ContainsKey("FunctionName") && - d["FunctionName"] == "TestFunction" - ) - ); - } public void Dispose() { diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs index 6d82cc92..a0d7bef5 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs @@ -35,45 +35,6 @@ public void Metrics_Set_Execution_Environment_Context() env.Received(1).GetEnvironmentVariable("AWS_EXECUTION_ENV"); } - [Fact] - public void Before_With_Null_DefaultDimensions_Should_Not_Throw() - { - // Arrange - MetricsAspect.ResetForTest(); - var metricsMock = Substitute.For(); - var optionsMock = new MetricsOptions - { - CaptureColdStart = true, - DefaultDimensions = null - }; - metricsMock.Options.Returns(optionsMock); - Metrics.UseMetricsForTests(metricsMock); - - var metricsAspect = new MetricsAspect(); - var method = typeof(MetricsTests).GetMethod(nameof(TestMethod)); - var trigger = new MetricsAttribute(); - - // Act - metricsAspect.Before( - this, - "TestMethod", - [new TestLambdaContext()], - typeof(MetricsTests), - method, - typeof(void), - [trigger] - ); - - // Assert - metricsMock.Received(1).PushSingleMetric( - "ColdStart", - 1.0, - MetricUnit.Count, - Arg.Any(), - Arg.Any() - ); - } - [Fact] public void Before_When_CaptureStartNotSet_Should_Not_Push_Metrics() { @@ -144,7 +105,7 @@ public void Before_When_RaiseOnEmptyMetricsNotSet_Should_Configure_Null() internal void TestMethod(ILambdaContext context) { } - + [Fact] public void When_Constructor_With_Namespace_And_Service_Should_Set_Both() { @@ -176,7 +137,7 @@ public void When_Constructor_With_Null_Namespace_And_Service_Should_Not_Set() Assert.Null(metrics.GetNamespace()); Assert.Null(metrics.Options.Service); } - + [Fact] public void When_AddMetric_With_EmptyKey_Should_ThrowArgumentNullException() { @@ -188,7 +149,8 @@ public void When_AddMetric_With_EmptyKey_Should_ThrowArgumentNullException() // Act & Assert var exception = Assert.Throws(() => metrics.AddMetric("", 1.0)); Assert.Equal("key", exception.ParamName); - Assert.Contains("'AddMetric' method requires a valid metrics key. 'Null' or empty values are not allowed.", exception.Message); + Assert.Contains("'AddMetric' method requires a valid metrics key. 'Null' or empty values are not allowed.", + exception.Message); } [Theory] @@ -205,23 +167,24 @@ public void When_AddMetric_With_InvalidKey_Should_ThrowArgumentNullException(str // Act & Assert var exception = Assert.Throws(() => metrics.AddMetric(key, 1.0)); Assert.Equal("key", exception.ParamName); - Assert.Contains("'AddMetric' method requires a valid metrics key. 'Null' or empty values are not allowed.", exception.Message); + Assert.Contains("'AddMetric' method requires a valid metrics key. 'Null' or empty values are not allowed.", + exception.Message); } - + [Fact] public void When_SetDefaultDimensions_With_InvalidKeyOrValue_Should_ThrowArgumentNullException() { // Arrange var powertoolsConfigMock = Substitute.For(); IMetrics metrics = new Metrics(powertoolsConfigMock); - + var invalidDimensions = new Dictionary { { "", "value" }, // empty key - { "key", "" }, // empty value + { "key", "" }, // empty value { " ", "value" }, // whitespace key { "key1", " " }, // whitespace value - { "key2", null } // null value + { "key2", null } // null value }; // Act & Assert @@ -230,10 +193,12 @@ public void When_SetDefaultDimensions_With_InvalidKeyOrValue_Should_ThrowArgumen var dimensions = new Dictionary { { dimension.Key, dimension.Value } }; var exception = Assert.Throws(() => metrics.SetDefaultDimensions(dimensions)); Assert.Equal("Key", exception.ParamName); - Assert.Contains("'SetDefaultDimensions' method requires a valid key pair. 'Null' or empty values are not allowed.", exception.Message); + Assert.Contains( + "'SetDefaultDimensions' method requires a valid key pair. 'Null' or empty values are not allowed.", + exception.Message); } } - + [Fact] public void When_PushSingleMetric_With_EmptyName_Should_ThrowArgumentNullException() { @@ -244,7 +209,9 @@ public void When_PushSingleMetric_With_EmptyName_Should_ThrowArgumentNullExcepti // Act & Assert var exception = Assert.Throws(() => metrics.PushSingleMetric("", 1.0, MetricUnit.Count)); Assert.Equal("name", exception.ParamName); - Assert.Contains("'PushSingleMetric' method requires a valid metrics key. 'Null' or empty values are not allowed.", exception.Message); + Assert.Contains( + "'PushSingleMetric' method requires a valid metrics key. 'Null' or empty values are not allowed.", + exception.Message); } [Theory] @@ -258,8 +225,76 @@ public void When_PushSingleMetric_With_InvalidName_Should_ThrowArgumentNullExcep IMetrics metrics = new Metrics(powertoolsConfigMock); // Act & Assert - var exception = Assert.Throws(() => metrics.PushSingleMetric(name, 1.0, MetricUnit.Count)); + var exception = + Assert.Throws(() => metrics.PushSingleMetric(name, 1.0, MetricUnit.Count)); Assert.Equal("name", exception.ParamName); - Assert.Contains("'PushSingleMetric' method requires a valid metrics key. 'Null' or empty values are not allowed.", exception.Message); + Assert.Contains( + "'PushSingleMetric' method requires a valid metrics key. 'Null' or empty values are not allowed.", + exception.Message); + } + + + [Fact] + public void When_ColdStart_Should_Use_DefaultDimensions_From_Options() + { + // Arrange + var options = new MetricsOptions + { + CaptureColdStart = true, + Namespace = "dotnet-powertools-test", + Service = "testService", + DefaultDimensions = new Dictionary + { + { "Environment", "Test" }, + { "Region", "us-east-1" } + } + }; + + var conf = Substitute.For(); + var consoleWrapper = Substitute.For(); + IMetrics metrics = new Metrics(conf, consoleWrapper: consoleWrapper, options: options); + + var context = new TestLambdaContext + { + FunctionName = "TestFunction" + }; + + // Act + metrics.CaptureColdStartMetric(context); + + // Assert + consoleWrapper.Received(1).WriteLine( + Arg.Is(s => s.Contains("\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Environment\",\"Region\",\"FunctionName\"]]}]},\"Environment\":\"Test\",\"Region\":\"us-east-1\",\"FunctionName\":\"TestFunction\",\"ColdStart\":1}")) + ); + } + + [Fact] + public void When_ColdStart_And_DefaultDimensions_Is_Null_Should_Only_Add_Service_And_FunctionName() + { + // Arrange + var options = new MetricsOptions + { + CaptureColdStart = true, + Namespace = "dotnet-powertools-test", + Service = "testService", + DefaultDimensions = null + }; + + var conf = Substitute.For(); + var consoleWrapper = Substitute.For(); + IMetrics metrics = new Metrics(conf, consoleWrapper: consoleWrapper, options: options); + + var context = new TestLambdaContext + { + FunctionName = "TestFunction" + }; + + // Act + metrics.CaptureColdStartMetric(context); + + // Assert + consoleWrapper.Received(1).WriteLine( + Arg.Is(s => s.Contains("\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"FunctionName\"]]}]},\"FunctionName\":\"TestFunction\",\"ColdStart\":1}")) + ); } } \ No newline at end of file diff --git a/libraries/tests/Directory.Packages.props b/libraries/tests/Directory.Packages.props index e8c9a16e..516a0e93 100644 --- a/libraries/tests/Directory.Packages.props +++ b/libraries/tests/Directory.Packages.props @@ -6,6 +6,7 @@ + From 3688e74e8f1adec401ec2b03687595177358bfc3 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:50:05 +0000 Subject: [PATCH 22/29] feat(tests): add unit tests for ConsoleWrapper and Metrics middleware extensions --- .../ConsoleWrapperTests.cs | 53 +++++++++ .../MetricsMiddlewareExtensionsTests.cs | 104 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 libraries/tests/AWS.Lambda.Powertools.Common.Tests/ConsoleWrapperTests.cs create mode 100644 libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsMiddlewareExtensionsTests.cs diff --git a/libraries/tests/AWS.Lambda.Powertools.Common.Tests/ConsoleWrapperTests.cs b/libraries/tests/AWS.Lambda.Powertools.Common.Tests/ConsoleWrapperTests.cs new file mode 100644 index 00000000..6395f79a --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Common.Tests/ConsoleWrapperTests.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using Xunit; + +namespace AWS.Lambda.Powertools.Common.Tests; + +public class ConsoleWrapperTests +{ + [Fact] + public void WriteLine_Should_Write_To_Console() + { + // Arrange + var consoleWrapper = new ConsoleWrapper(); + var writer = new StringWriter(); + Console.SetOut(writer); + + // Act + consoleWrapper.WriteLine("test message"); + + // Assert + Assert.Equal($"test message{Environment.NewLine}", writer.ToString()); + } + + [Fact] + public void Error_Should_Write_To_Error_Console() + { + // Arrange + var consoleWrapper = new ConsoleWrapper(); + var writer = new StringWriter(); + Console.SetError(writer); + + // Act + consoleWrapper.Error("error message"); + + // Assert + Assert.Equal($"error message{Environment.NewLine}", writer.ToString()); + } + + [Fact] + public void ReadLine_Should_Read_From_Console() + { + // Arrange + var consoleWrapper = new ConsoleWrapper(); + var reader = new StringReader("input text"); + Console.SetIn(reader); + + // Act + var result = consoleWrapper.ReadLine(); + + // Assert + Assert.Equal("input text", result); + } +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsMiddlewareExtensionsTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsMiddlewareExtensionsTests.cs new file mode 100644 index 00000000..d72bad66 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsMiddlewareExtensionsTests.cs @@ -0,0 +1,104 @@ +using Amazon.Lambda.TestUtilities; +using AWS.Lambda.Powertools.Common; +using AWS.Lambda.Powertools.Metrics.AspNetCore.Http; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Xunit; + +namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Tests; + +public class MetricsMiddlewareExtensionsTests : IDisposable +{ + [Fact] + public async Task When_UseMetrics_Should_Add_ColdStart() + { + // Arrange + var options = new MetricsOptions + { + CaptureColdStart = true, + Namespace = "TestNamespace", + Service = "TestService" + }; + + var conf = Substitute.For(); + var consoleWrapper = Substitute.For(); + var metrics = new Metrics(conf, consoleWrapper: consoleWrapper, options: options); + + var builder = WebApplication.CreateBuilder(); + builder.Services.AddSingleton(metrics); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.UseMetrics(); + app.MapGet("/test", () => Results.Ok()); + + await app.StartAsync(); + var client = app.GetTestClient(); + + // Act + var response = await client.GetAsync("/test"); + + // Assert + Assert.Equal(200, (int)response.StatusCode); + consoleWrapper.Received(1).WriteLine( + Arg.Is(s => s.Contains("CloudWatchMetrics\":[{\"Namespace\":\"TestNamespace\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[]]}]},\"ColdStart\":1}")) + ); + + await app.StopAsync(); + } + + [Fact] + public async Task When_UseMetrics_Should_Add_ColdStart_With_LambdaContext() + { + // Arrange + var options = new MetricsOptions + { + CaptureColdStart = true, + Namespace = "TestNamespace", + Service = "TestService" + }; + + var conf = Substitute.For(); + var consoleWrapper = Substitute.For(); + var metrics = new Metrics(conf, consoleWrapper: consoleWrapper, options: options); + + var builder = WebApplication.CreateBuilder(); + builder.Services.AddSingleton(metrics); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.Use(async (context, next) => + { + var lambdaContext = new TestLambdaContext + { + FunctionName = "TestFunction" + }; + context.Items["LambdaContext"] = lambdaContext; + await next(); + }); + app.UseMetrics(); + app.MapGet("/test", () => Results.Ok()); + + await app.StartAsync(); + var client = app.GetTestClient(); + + // Act + var response = await client.GetAsync("/test"); + + // Assert + Assert.Equal(200, (int)response.StatusCode); + consoleWrapper.Received(1).WriteLine( + Arg.Is(s => s.Contains("CloudWatchMetrics\":[{\"Namespace\":\"TestNamespace\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"FunctionName\"]]}]},\"FunctionName\":\"TestFunction\",\"ColdStart\":1}")) + ); + + await app.StopAsync(); + } + + public void Dispose() + { + MetricsHelper.ResetColdStart(); + } +} \ No newline at end of file From 6431e1a945c578f0af6c8384163c08e6f6ba9d66 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Thu, 27 Feb 2025 09:31:39 +0000 Subject: [PATCH 23/29] feat(metrics): add ColdStartTracker for tracking cold starts in ASP.NET Core applications --- .../Http/ColdStartTracker.cs | 76 +++++++++++++++++++ .../Http/MetricsFilter.cs | 29 ++++--- .../Http/MetricsHelper.cs | 66 ---------------- .../Http/MetricsMiddlewareExtensions.cs | 19 +++-- .../MetricsEndpointExtensionsTests.cs | 4 +- .../MetricsFilterTests.cs | 10 +-- .../MetricsMiddlewareExtensionsTests.cs | 5 +- .../Handlers/FunctionHandler.cs | 2 - .../MetricsTests.cs | 54 ------------- 9 files changed, 120 insertions(+), 145 deletions(-) create mode 100644 libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/ColdStartTracker.cs delete mode 100644 libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsHelper.cs diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/ColdStartTracker.cs b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/ColdStartTracker.cs new file mode 100644 index 00000000..aafaad26 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/ColdStartTracker.cs @@ -0,0 +1,76 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using Amazon.Lambda.Core; +using Microsoft.AspNetCore.Http; + +namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Http; + + +/// +/// Tracks and manages cold start metrics for Lambda functions in ASP.NET Core applications. +/// +/// +/// This class is responsible for detecting and recording the first invocation (cold start) of a Lambda function. +/// It ensures thread-safe tracking of cold starts and proper metric capture using the provided IMetrics implementation. +/// +internal class ColdStartTracker : IDisposable +{ + private readonly IMetrics _metrics; + private static bool _coldStart = true; + private static readonly object _lock = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The metrics implementation to use for capturing cold start metrics. + public ColdStartTracker(IMetrics metrics) + { + _metrics = metrics; + } + + /// + /// Tracks the cold start of the Lambda function. + /// + /// The current HTTP context. + internal void TrackColdStart(HttpContext context) + { + if (!_coldStart) return; + + lock (_lock) + { + if (!_coldStart) return; + _metrics.CaptureColdStartMetric(context.Items["LambdaContext"] as ILambdaContext); + _coldStart = false; + } + } + + /// + /// Resets the cold start tracking state. + /// + internal static void ResetColdStart() + { + lock (_lock) + { + _coldStart = true; + } + } + + /// + public void Dispose() + { + ResetColdStart(); + } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsFilter.cs b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsFilter.cs index a2c776f1..f89fd94b 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsFilter.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsFilter.cs @@ -20,17 +20,22 @@ namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Http; /// /// Represents a filter that captures and records metrics for HTTP endpoints. /// -public class MetricsFilter : IEndpointFilter +/// +/// This filter is responsible for tracking cold starts and capturing metrics during HTTP request processing. +/// It integrates with the ASP.NET Core endpoint routing system to inject metrics collection at the endpoint level. +/// +/// +/// +public class MetricsFilter : IEndpointFilter, IDisposable { - private readonly MetricsHelper _metricsHelper; + private readonly ColdStartTracker _coldStartTracker; /// /// Initializes a new instance of the class. /// - /// The metrics instance to use for recording metrics. public MetricsFilter(IMetrics metrics) { - _metricsHelper = new MetricsHelper(metrics); + _coldStartTracker = new ColdStartTracker(metrics); } /// @@ -41,17 +46,23 @@ public MetricsFilter(IMetrics metrics) /// A task that represents the asynchronous operation, containing the result of the endpoint invocation. public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { - var result = await next(context); - try { - await _metricsHelper.CaptureColdStartMetrics(context.HttpContext); - return result; + _coldStartTracker.TrackColdStart(context.HttpContext); } catch { // ignored - return result; } + + return await next(context); + } + + /// + /// Disposes of the resources used by the filter. + /// + public void Dispose() + { + _coldStartTracker.Dispose(); } } \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsHelper.cs b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsHelper.cs deleted file mode 100644 index a86ff0b0..00000000 --- a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsHelper.cs +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -using Amazon.Lambda.Core; -using Microsoft.AspNetCore.Http; - -namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Http; - - -/// -/// Helper class for capturing and recording metrics in ASP.NET Core applications. -/// -public class MetricsHelper -{ - private readonly IMetrics _metrics; - private static bool _isColdStart = true; - - /// - /// Initializes a new instance of the class. - /// - /// The metrics instance to use for recording metrics. - public MetricsHelper(IMetrics metrics) - { - _metrics = metrics; - } - - /// - /// Captures cold start metrics for the given HTTP context. - /// - /// The HTTP context. - /// A task that represents the asynchronous operation. - public Task CaptureColdStartMetrics(HttpContext context) - { - if (!_isColdStart) - return Task.CompletedTask; - - lock (_metrics) - { - _isColdStart = false; - } - - _metrics.CaptureColdStartMetric(context.Items["LambdaContext"] as ILambdaContext); - - return Task.CompletedTask; - } - - /// - /// Resets the cold start flag for testing purposes. - /// - internal static void ResetColdStart() - { - _isColdStart = true; - } -} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsMiddlewareExtensions.cs b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsMiddlewareExtensions.cs index 05e0d3f9..7515c1b5 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsMiddlewareExtensions.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics.AspNetCore/Http/MetricsMiddlewareExtensions.cs @@ -24,18 +24,27 @@ namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Http; public static class MetricsMiddlewareExtensions { /// - /// Adds middleware to capture and record metrics for HTTP requests. + /// Adds middleware to capture and record metrics for HTTP requests, including cold start tracking. /// - /// The application builder. + /// The application builder instance used to configure the request pipeline. /// The application builder with the metrics middleware added. + /// + /// This middleware tracks cold starts and captures request metrics. To use this middleware, ensure you have registered + /// the required services using builder.Services.AddSingleton<IMetrics>() in your service configuration. + /// + /// + /// + /// app.UseMetrics(); + /// + /// public static IApplicationBuilder UseMetrics(this IApplicationBuilder app) { return app.Use(async (context, next) => { var metrics = context.RequestServices.GetRequiredService(); - var metricsHelper = new MetricsHelper(metrics); - await metricsHelper.CaptureColdStartMetrics(context); + using var metricsHelper = new ColdStartTracker(metrics); + metricsHelper.TrackColdStart(context); await next(); }); } -} \ No newline at end of file +} diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsEndpointExtensionsTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsEndpointExtensionsTests.cs index 155d6e29..c5ee7c2c 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsEndpointExtensionsTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsEndpointExtensionsTests.cs @@ -10,6 +10,7 @@ namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Tests; +[Collection("Metrics")] public class MetricsEndpointExtensionsTests : IDisposable { [Fact] @@ -26,7 +27,6 @@ public async Task When_WithMetrics_Should_Add_ColdStart() var conf = Substitute.For(); var consoleWrapper = Substitute.For(); var metrics = new Metrics(conf, consoleWrapper: consoleWrapper, options: options); - var builder = WebApplication.CreateBuilder(); builder.Services.AddSingleton(metrics); builder.WebHost.UseTestServer(); @@ -159,6 +159,6 @@ public async Task When_WithMetrics_Should_Add_ColdStart_Default_Dimensions() public void Dispose() { - MetricsHelper.ResetColdStart(); + ColdStartTracker.ResetColdStart(); } } \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsFilterTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsFilterTests.cs index d4dad622..9951034a 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsFilterTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsFilterTests.cs @@ -6,21 +6,21 @@ namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Tests; +[Collection("Metrics")] public class MetricsFilterTests : IDisposable { private readonly IMetrics _metrics; private readonly EndpointFilterInvocationContext _context; - private readonly ILambdaContext _lambdaContext; public MetricsFilterTests() { - MetricsHelper.ResetColdStart(); // Reset before each test + ColdStartTracker.ResetColdStart(); // Reset before each test _metrics = Substitute.For(); _context = Substitute.For(); - _lambdaContext = Substitute.For(); + var lambdaContext = Substitute.For(); var httpContext = new DefaultHttpContext(); - httpContext.Items["LambdaContext"] = _lambdaContext; + httpContext.Items["LambdaContext"] = lambdaContext; _context.HttpContext.Returns(httpContext); } @@ -71,6 +71,6 @@ public async Task InvokeAsync_ShouldCallNextAndContinue() public void Dispose() { - MetricsHelper.ResetColdStart(); + ColdStartTracker.ResetColdStart(); } } \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsMiddlewareExtensionsTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsMiddlewareExtensionsTests.cs index d72bad66..a9510eaa 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsMiddlewareExtensionsTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.AspNetCore.Tests/MetricsMiddlewareExtensionsTests.cs @@ -10,6 +10,7 @@ namespace AWS.Lambda.Powertools.Metrics.AspNetCore.Tests; +[Collection("Metrics")] public class MetricsMiddlewareExtensionsTests : IDisposable { [Fact] @@ -63,7 +64,7 @@ public async Task When_UseMetrics_Should_Add_ColdStart_With_LambdaContext() var conf = Substitute.For(); var consoleWrapper = Substitute.For(); - var metrics = new Metrics(conf, consoleWrapper: consoleWrapper, options: options); + var metrics = new Metrics(conf, consoleWrapper:consoleWrapper, options: options); var builder = WebApplication.CreateBuilder(); builder.Services.AddSingleton(metrics); @@ -99,6 +100,6 @@ public async Task When_UseMetrics_Should_Add_ColdStart_With_LambdaContext() public void Dispose() { - MetricsHelper.ResetColdStart(); + ColdStartTracker.ResetColdStart(); } } \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs index abc41d7f..80c658f1 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs @@ -14,13 +14,11 @@ */ using System; -using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading.Tasks; using Amazon.Lambda.Core; -using Amazon.Lambda.TestUtilities; namespace AWS.Lambda.Powertools.Metrics.Tests.Handlers; diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs index a0d7bef5..36039756 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs @@ -35,45 +35,6 @@ public void Metrics_Set_Execution_Environment_Context() env.Received(1).GetEnvironmentVariable("AWS_EXECUTION_ENV"); } - [Fact] - public void Before_When_CaptureStartNotSet_Should_Not_Push_Metrics() - { - // Arrange - MetricsAspect.ResetForTest(); - var metricsMock = Substitute.For(); - var optionsMock = new MetricsOptions - { - CaptureColdStart = null - }; - metricsMock.Options.Returns(optionsMock); - Metrics.UseMetricsForTests(metricsMock); - - var metricsAspect = new MetricsAspect(); - var method = typeof(MetricsTests).GetMethod(nameof(TestMethod)); - var trigger = new MetricsAttribute(); - - // Act - metricsAspect.Before( - this, - "TestMethod", - new object[] { new TestLambdaContext() }, - typeof(MetricsTests), - method, - typeof(void), - new Attribute[] { trigger } - ); - - // Assert - metricsMock.DidNotReceive().PushSingleMetric( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any>() - ); - } - [Fact] public void Before_When_RaiseOnEmptyMetricsNotSet_Should_Configure_Null() { @@ -106,21 +67,6 @@ internal void TestMethod(ILambdaContext context) { } - [Fact] - public void When_Constructor_With_Namespace_And_Service_Should_Set_Both() - { - // Arrange - Substitute.For(); - var powertoolsConfigMock = Substitute.For(); - - // Act - var metrics = new Metrics(powertoolsConfigMock, "TestNamespace", "TestService"); - - // Assert - Assert.Equal("TestNamespace", metrics.GetNamespace()); - Assert.Equal("TestService", metrics.Options.Service); - } - [Fact] public void When_Constructor_With_Null_Namespace_And_Service_Should_Not_Set() { From 089f505ca54d898c06346967c79d62ab6496a2c7 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:02:45 +0000 Subject: [PATCH 24/29] feat(metrics): add Metrics.AspNetCore version to version.json --- version.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/version.json b/version.json index d52ea67c..34abfcfc 100644 --- a/version.json +++ b/version.json @@ -2,7 +2,8 @@ "Core": { "Logging": "1.6.4", "Metrics": "1.8.0", - "Tracing": "1.6.1" + "Tracing": "1.6.1", + "Metrics.AspNetCore": "0.1.0", }, "Utilities": { "Parameters": "1.3.0", From 6ca8d328b12966465e99c82944245afa121615e6 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Thu, 27 Feb 2025 11:21:46 +0000 Subject: [PATCH 25/29] feat(metrics): update default dimensions handling and increase maximum dimensions limit --- docs/core/metrics-v2.md | 5 +++-- .../AWS.Lambda.Powertools.Metrics/IMetrics.cs | 4 ++-- .../AWS.Lambda.Powertools.Metrics/Metrics.cs | 22 +++++++++---------- .../Model/MetricsContext.cs | 12 ++++++++++ .../Handlers/FunctionHandler.cs | 8 +++---- .../Function/src/Function/TestHelper.cs | 2 +- mkdocs.yml | 5 +++-- 7 files changed, 36 insertions(+), 22 deletions(-) diff --git a/docs/core/metrics-v2.md b/docs/core/metrics-v2.md index 7c31580d..ec6c536e 100644 --- a/docs/core/metrics-v2.md +++ b/docs/core/metrics-v2.md @@ -20,6 +20,7 @@ These metrics can be visualized through [Amazon CloudWatch Console](https://aws. * **`Dimensions`** outputs as an array of arrays instead of an array of objects. Example: `Dimensions: [["service", "Environment"]]` instead of `Dimensions: ["service", "Environment"]` * **`FunctionName`** is not added as default dimension and only to cold start metric. +* **`Default Dimensions`** can now be included in Cold Start metrics, this is a potential breaking change if you were relying on the absence of default dimensions in Cold Start metrics when searching.
@@ -440,7 +441,7 @@ During metrics validation, if no metrics are provided then a warning will be log !!! tip "Metric validation" If metrics are provided, and any of the following criteria are not met, **`SchemaValidationException`** will be raised: - * Maximum of 9 dimensions + * Maximum of 30 dimensions * Namespace is set * Metric units must be [supported by CloudWatch](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_MetricDatum.html) @@ -615,7 +616,7 @@ CloudWatch EMF uses the same dimensions across all your metrics. Use **`PushSing unit: MetricUnit.Count, nameSpace: "ExampleApplication", service: "Booking", - defaultDimensions: new Dictionary + dimensions: new Dictionary { {"FunctionContext", "$LATEST"} }); diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs index 61e15b7b..02164e8a 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs @@ -86,10 +86,10 @@ void AddMetric(string key, double value, MetricUnit unit = MetricUnit.None, /// The metric unit. /// The namespace. /// The service name. - /// The default dimensions. + /// The default dimensions. /// The metric resolution. void PushSingleMetric(string name, double value, MetricUnit unit, string nameSpace = null, string service = null, - Dictionary defaultDimensions = null, MetricResolution resolution = MetricResolution.Default); + Dictionary dimensions = null, MetricResolution resolution = MetricResolution.Default); /// /// Clears the default dimensions. diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs index 06b1dd18..510fde9f 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs @@ -307,7 +307,7 @@ private Dictionary GetDefaultDimensions() /// void IMetrics.PushSingleMetric(string name, double value, MetricUnit unit, string nameSpace, - string service, Dictionary defaultDimensions, MetricResolution resolution) + string service, Dictionary dimensions, MetricResolution resolution) { if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name), @@ -317,10 +317,10 @@ void IMetrics.PushSingleMetric(string name, double value, MetricUnit unit, strin context.SetNamespace(nameSpace ?? GetNamespace()); context.SetService(service ?? _context.GetService()); - if (defaultDimensions != null) + if (dimensions != null) { - var defaultDimensionsList = DictionaryToList(defaultDimensions); - context.SetDefaultDimensions(defaultDimensionsList); + var dimensionsList = DictionaryToList(dimensions); + context.AddDimensions(dimensionsList); } context.AddMetric(name, value, unit, resolution); @@ -415,7 +415,7 @@ public static void AddMetadata(string key, object value) /// Default Dimension List public static void SetDefaultDimensions(Dictionary defaultDimensions) { - Instance?.SetDefaultDimensions(defaultDimensions); + Instance.SetDefaultDimensions(defaultDimensions); } /// @@ -447,13 +447,13 @@ private void Flush(MetricsContext context) /// Metric Unit /// Metric Namespace /// Service Name - /// Default dimensions list + /// Default dimensions list /// Metrics resolution public static void PushSingleMetric(string name, double value, MetricUnit unit, string nameSpace = null, - string service = null, Dictionary defaultDimensions = null, + string service = null, Dictionary dimensions = null, MetricResolution resolution = MetricResolution.Default) { - Instance.PushSingleMetric(name, value, unit, nameSpace, service, defaultDimensions, + Instance.PushSingleMetric(name, value, unit, nameSpace, service, dimensions, resolution); } @@ -464,12 +464,12 @@ public static void PushSingleMetric(string name, double value, MetricUnit unit, /// Default dimensions list private List DictionaryToList(Dictionary defaultDimensions) { - var defaultDimensionsList = new List(); + var dimensionsList = new List(); if (defaultDimensions != null) foreach (var item in defaultDimensions) - defaultDimensionsList.Add(new DimensionSet(item.Key, item.Value)); + dimensionsList.Add(new DimensionSet(item.Key, item.Value)); - return defaultDimensionsList; + return dimensionsList; } private Dictionary ListToDictionary(List dimensions) diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricsContext.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricsContext.cs index 8e886a90..759cdb9e 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricsContext.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricsContext.cs @@ -135,6 +135,18 @@ public void AddDimension(string key, string value) _rootNode.AWS.AddDimensionSet(new DimensionSet(key, value)); } + /// + /// Adds new dimensions to memory + /// + /// List of dimensions + public void AddDimensions(List dimensions) + { + foreach (var dimension in dimensions) + { + _rootNode.AWS.AddDimensionSet(dimension); + } + } + /// /// Sets default dimensions list /// diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs index 80c658f1..910ca0a9 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs @@ -41,12 +41,12 @@ public void AddDimensions() public void AddMultipleDimensions() { Metrics.PushSingleMetric("SingleMetric1", 1, MetricUnit.Count, resolution: MetricResolution.High, - defaultDimensions: new Dictionary { + dimensions: new Dictionary { { "Default1", "SingleMetric1" } }); Metrics.PushSingleMetric("SingleMetric2", 1, MetricUnit.Count, resolution: MetricResolution.High, nameSpace: "ns2", - defaultDimensions: new Dictionary { + dimensions: new Dictionary { { "Default1", "SingleMetric2" }, { "Default2", "SingleMetric2" } }); @@ -58,7 +58,7 @@ public void AddMultipleDimensions() public void PushSingleMetricWithNamespace() { Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count, resolution: MetricResolution.High, - defaultDimensions: new Dictionary { + dimensions: new Dictionary { { "Default", "SingleMetric" } }); } @@ -67,7 +67,7 @@ public void PushSingleMetricWithNamespace() public void PushSingleMetricWithEnvNamespace() { Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count, resolution: MetricResolution.High, - defaultDimensions: new Dictionary { + dimensions: new Dictionary { { "Default", "SingleMetric" } }); } diff --git a/libraries/tests/e2e/functions/core/metrics/Function/src/Function/TestHelper.cs b/libraries/tests/e2e/functions/core/metrics/Function/src/Function/TestHelper.cs index c3434d28..38cb7438 100644 --- a/libraries/tests/e2e/functions/core/metrics/Function/src/Function/TestHelper.cs +++ b/libraries/tests/e2e/functions/core/metrics/Function/src/Function/TestHelper.cs @@ -38,7 +38,7 @@ public static void TestMethod(APIGatewayProxyRequest apigwProxyEvent, ILambdaCon unit: MetricUnit.Count, nameSpace: "Test", service: "Test", - defaultDimensions: new Dictionary + dimensions: new Dictionary { {"FunctionName", context.FunctionName} }); diff --git a/mkdocs.yml b/mkdocs.yml index 9170105e..24f86cf6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,8 +15,9 @@ nav: - Workshop 🆕: https://s12d.com/powertools-for-aws-lambda-workshop" target="_blank - Core utilities: - core/logging.md - - core/metrics.md - - core/metrics-v2.md + - Metrics: + - core/metrics.md + - core/metrics-v2.md - core/tracing.md - Utilities: - utilities/parameters.md From 4b68241ce17639e084cc0dd5976a7f0da825f480 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Thu, 27 Feb 2025 11:28:05 +0000 Subject: [PATCH 26/29] feat(build): increase verbosity for test and example runs in CI pipeline --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f0102235..a5a12869 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,10 +37,10 @@ jobs: run: dotnet build --configuration Release --no-restore /tl - name: Test & Code Coverage - run: dotnet test --no-restore --filter "Category!=E2E" --collect:"XPlat Code Coverage" --results-directory ./codecov --verbosity quiet + run: dotnet test --no-restore --filter "Category!=E2E" --collect:"XPlat Code Coverage" --results-directory ./codecov --verbosity normal - name: Test Examples - run: dotnet test ../examples/ --verbosity quiet + run: dotnet test ../examples/ --verbosity normal - name: Codecov uses: codecov/codecov-action@13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3 # 5.3.1 From 367d990897681939b2510c73b1ee3e4a391c4528 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Thu, 27 Feb 2025 11:49:02 +0000 Subject: [PATCH 27/29] feat(version): update Metrics version to 2.0.0 in version.json --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 34abfcfc..8ddbf994 100644 --- a/version.json +++ b/version.json @@ -1,7 +1,7 @@ { "Core": { "Logging": "1.6.4", - "Metrics": "1.8.0", + "Metrics": "2.0.0", "Tracing": "1.6.1", "Metrics.AspNetCore": "0.1.0", }, From eab141d62cce9195906f46c9bf9143d2e42751a0 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Thu, 27 Feb 2025 12:33:54 +0000 Subject: [PATCH 28/29] update docs --- docs/core/metrics-v2.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/core/metrics-v2.md b/docs/core/metrics-v2.md index fb71e795..4854bae5 100644 --- a/docs/core/metrics-v2.md +++ b/docs/core/metrics-v2.md @@ -619,7 +619,7 @@ CloudWatch EMF uses the same dimensions across all your metrics. Use **`PushSing ... ``` -By default it will skip all previously defined dimensions including default dimensions. Use default_dimensions keyword argument if you want to reuse default dimensions or specify custom dimensions from a dictionary. +By default it will skip all previously defined dimensions including default dimensions. Use `dimensions` argument if you want to reuse default dimensions or specify custom dimensions from a dictionary. - `Metrics.DefaultDimensions`: Reuse default dimensions when using static Metrics - `Options.DefaultDimensions`: Reuse default dimensions when using Builder or Configure patterns @@ -660,7 +660,7 @@ By default it will skip all previously defined dimensions including default dime { { "Default", "SingleMetric" } }); - Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count, defaultDimensions: Metrics.DefaultDimensions ); + Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count, dimensions: Metrics.DefaultDimensions ); ... ``` === "Default Dimensions Options / Builder patterns .cs" @@ -683,7 +683,7 @@ By default it will skip all previously defined dimensions including default dime public void HandlerSingleMetricDimensions() { - _metrics.PushSingleMetric("SuccessfulBooking", 1, MetricUnit.Count, defaultDimensions: _metrics.Options.DefaultDimensions); + _metrics.PushSingleMetric("SuccessfulBooking", 1, MetricUnit.Count, dimensions: _metrics.Options.DefaultDimensions); } ... ``` From ce9bf85bdb9382b44ff742aa4d4c05996c4bcbad Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Thu, 27 Feb 2025 12:38:26 +0000 Subject: [PATCH 29/29] add metrics tests --- .../MetricsTests.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs index 36039756..ecdd94d6 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs @@ -243,4 +243,82 @@ public void When_ColdStart_And_DefaultDimensions_Is_Null_Should_Only_Add_Service Arg.Is(s => s.Contains("\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"FunctionName\"]]}]},\"FunctionName\":\"TestFunction\",\"ColdStart\":1}")) ); } + + [Fact] + public void Namespace_Should_Return_OptionsNamespace() + { + // Arrange + Metrics.ResetForTest(); + var metricsMock = Substitute.For(); + var optionsMock = new MetricsOptions + { + Namespace = "TestNamespace" + }; + + metricsMock.Options.Returns(optionsMock); + Metrics.UseMetricsForTests(metricsMock); + + // Act + var result = Metrics.Namespace; + + // Assert + Assert.Equal("TestNamespace", result); + } + + [Fact] + public void Service_Should_Return_OptionsService() + { + // Arrange + Metrics.ResetForTest(); + var metricsMock = Substitute.For(); + var optionsMock = new MetricsOptions + { + Service = "TestService" + }; + + metricsMock.Options.Returns(optionsMock); + Metrics.UseMetricsForTests(metricsMock); + + // Act + var result = Metrics.Service; + + // Assert + Assert.Equal("TestService", result); + } + + [Fact] + public void Namespace_Should_Return_Null_When_Not_Set() + { + // Arrange + Metrics.ResetForTest(); + var metricsMock = Substitute.For(); + var optionsMock = new MetricsOptions(); + + metricsMock.Options.Returns(optionsMock); + Metrics.UseMetricsForTests(metricsMock); + + // Act + var result = Metrics.Namespace; + + // Assert + Assert.Null(result); + } + + [Fact] + public void Service_Should_Return_Null_When_Not_Set() + { + // Arrange + Metrics.ResetForTest(); + var metricsMock = Substitute.For(); + var optionsMock = new MetricsOptions(); + + metricsMock.Options.Returns(optionsMock); + Metrics.UseMetricsForTests(metricsMock); + + // Act + var result = Metrics.Service; + + // Assert + Assert.Null(result); + } } \ No newline at end of file