From a1417eaa5fb58636739056aee3604ef6cef23380 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Wed, 19 Feb 2025 19:26:39 +0000 Subject: [PATCH 1/9] feat(metrics): enhance default dimensions handling and refactor metrics initialization. Adding default dimensions to cold start metrics --- .../AWS.Lambda.Powertools.Metrics/IMetrics.cs | 25 +- .../Internal/MetricsAspect.cs | 29 +-- .../AWS.Lambda.Powertools.Metrics/Metrics.cs | 225 ++++++++++++------ .../Model/Metadata.cs | 8 + .../Model/MetricsContext.cs | 8 + .../EMFValidationTests.cs | 1 + .../Handlers/DefaultDimensionsHandler.cs | 32 +++ .../Handlers/FunctionHandlerTests.cs | 47 +++- 8 files changed, 278 insertions(+), 97 deletions(-) create mode 100644 libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs index 6f0b868a..594c9073 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs @@ -66,7 +66,7 @@ public interface IMetrics /// Metrics resolution void PushSingleMetric(string metricName, double value, MetricUnit unit, string nameSpace = null, string service = null, Dictionary defaultDimensions = null, MetricResolution metricResolution = MetricResolution.Default); - + /// /// Sets the namespace /// @@ -101,4 +101,27 @@ void PushSingleMetric(string metricName, double value, MetricUnit unit, string n /// Clears both default dimensions and dimensions lists /// void ClearDefaultDimensions(); + + /// + /// Sets the service + /// + /// + void SetService(string triggerService); + + /// + /// Sets the raise on empty metrics + /// + /// + void SetRaiseOnEmptyMetrics(bool triggerRaiseOnEmptyMetrics); + + /// + /// Sets the capture cold start + /// + void SetCaptureColdStart(bool captureColdStart); + + /// + /// Gets the default dimensions + /// + Dictionary GetDefaultDimensions(); + } diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs index a72e205e..4e77cddc 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs @@ -70,13 +70,13 @@ public void Before( var trigger = triggers.OfType().First(); - _metricsInstance ??= new Metrics( - PowertoolsConfigurations.Instance, - trigger.Namespace, - trigger.Service, - trigger.RaiseOnEmptyMetrics, - trigger.CaptureColdStart - ); + _metricsInstance ??= Metrics.Instance; + _metricsInstance.SetService(trigger.Service); + _metricsInstance.SetNamespace(trigger.Namespace); + _metricsInstance.SetRaiseOnEmptyMetrics(trigger.RaiseOnEmptyMetrics); + _metricsInstance.SetCaptureColdStart(trigger.CaptureColdStart); + + var defaultDimensions = _metricsInstance.GetDefaultDimensions(); var eventArgs = new AspectEventArgs { @@ -95,16 +95,13 @@ public void Before( var nameSpace = _metricsInstance.GetNamespace(); var service = _metricsInstance.GetService(); - Dictionary dimensions = null; - + var context = GetContext(eventArgs); - + if (context is not null) { - dimensions = new Dictionary - { - { "FunctionName", context.FunctionName } - }; + defaultDimensions.Add("FunctionName", context.FunctionName); + _metricsInstance.SetDefaultDimensions(defaultDimensions); } _metricsInstance.PushSingleMetric( @@ -113,7 +110,7 @@ public void Before( MetricUnit.Count, nameSpace, service, - dimensions + defaultDimensions ); } } @@ -137,7 +134,7 @@ internal static void ResetForTest() _isColdStart = true; Metrics.ResetForTest(); } - + /// /// Gets the Lambda context /// diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs index 2ddbc539..26c91333 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using AWS.Lambda.Powertools.Common; @@ -27,6 +28,13 @@ namespace AWS.Lambda.Powertools.Metrics; /// public class Metrics : IMetrics, IDisposable { + static Metrics() + { + _instance = new Metrics(PowertoolsConfigurations.Instance); + } + + internal static IMetrics Instance => _instance ?? new Metrics(PowertoolsConfigurations.Instance); + /// /// The instance /// @@ -45,12 +53,12 @@ public class Metrics : IMetrics, IDisposable /// /// If true, Powertools for AWS Lambda (.NET) will throw an exception on empty metrics when trying to flush /// - private readonly bool _raiseOnEmptyMetrics; + private bool _raiseOnEmptyMetrics; /// /// The capture cold start enabled /// - private readonly bool _captureColdStartEnabled; + private bool _captureColdStartEnabled; // // Shared synchronization object @@ -70,14 +78,16 @@ public class Metrics : IMetrics, IDisposable internal Metrics(IPowertoolsConfigurations powertoolsConfigurations, string nameSpace = null, string service = null, bool raiseOnEmptyMetrics = false, bool captureColdStartEnabled = false) { - _instance ??= this; - - _powertoolsConfigurations = powertoolsConfigurations; - _raiseOnEmptyMetrics = raiseOnEmptyMetrics; - _captureColdStartEnabled = captureColdStartEnabled; - _context = InitializeContext(nameSpace, service, null); + if (_instance == null) + { + _instance = this; + _powertoolsConfigurations = powertoolsConfigurations; + _raiseOnEmptyMetrics = raiseOnEmptyMetrics; + _captureColdStartEnabled = captureColdStartEnabled; + _context = new MetricsContext(); - _powertoolsConfigurations.SetExecutionEnvironment(this); + _powertoolsConfigurations.SetExecutionEnvironment(this); + } } /// @@ -93,30 +103,38 @@ internal Metrics(IPowertoolsConfigurations powertoolsConfigurations, string name /// void IMetrics.AddMetric(string key, double value, MetricUnit unit, MetricResolution metricResolution) { - if (string.IsNullOrWhiteSpace(key)) - throw new ArgumentNullException( - nameof(key), - "'AddMetric' method requires a valid metrics key. 'Null' or empty values are not allowed."); - - if (value < 0) - { - throw new ArgumentException( - "'AddMetric' method requires a valid metrics value. Value must be >= 0.", nameof(value)); - } - - lock (_lockObj) + if (Instance != null) { - var metrics = _context.GetMetrics(); + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentNullException( + nameof(key), + "'AddMetric' method requires a valid metrics key. 'Null' or empty values are not allowed."); - if (metrics.Count > 0 && - (metrics.Count == PowertoolsConfigurations.MaxMetrics || - metrics.FirstOrDefault(x => x.Name == key) - ?.Values.Count == PowertoolsConfigurations.MaxMetrics)) + if (value < 0) { - _instance.Flush(true); + throw new ArgumentException( + "'AddMetric' method requires a valid metrics value. Value must be >= 0.", nameof(value)); } - _context.AddMetric(key, value, unit, metricResolution); + lock (_lockObj) + { + var metrics = _context.GetMetrics(); + + if (metrics.Count > 0 && + (metrics.Count == PowertoolsConfigurations.MaxMetrics || + metrics.FirstOrDefault(x => x.Name == key) + ?.Values.Count == PowertoolsConfigurations.MaxMetrics)) + { + Instance.Flush(true); + } + + _context.AddMetric(key, value, unit, metricResolution); + } + } + else + { + Debug.WriteLine( + $"##WARNING##: Metrics should be initialized in Handler method before calling {nameof(AddMetric)} method."); } } @@ -126,7 +144,9 @@ void IMetrics.AddMetric(string key, double value, MetricUnit unit, MetricResolut /// Metrics Namespace Identifier void IMetrics.SetNamespace(string nameSpace) { - _context.SetNamespace(nameSpace); + _context.SetNamespace(!string.IsNullOrWhiteSpace(nameSpace) + ? nameSpace + : Instance.GetNamespace() ?? _powertoolsConfigurations.MetricsNamespace); } /// @@ -244,7 +264,7 @@ void IMetrics.Flush(bool metricsOverflow) "##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'"); } } - + /// /// Clears both default dimensions and dimensions lists /// @@ -253,6 +273,43 @@ void IMetrics.ClearDefaultDimensions() _context.ClearDefaultDimensions(); } + /// + public void SetService(string service) + { + // this needs to check if service is set through code or env variables + // the default value service_undefined has to be ignored and return null so it is not added as default + var parsedService = !string.IsNullOrWhiteSpace(service) + ? service + : _powertoolsConfigurations.Service == "service_undefined" + ? null + : _powertoolsConfigurations.Service; + + if (parsedService != null) + { + _context.SetService(parsedService); + _context.SetDefaultDimensions(new List(new[] + { new DimensionSet("Service", _context.GetService()) })); + } + } + + /// + public void SetRaiseOnEmptyMetrics(bool raiseOnEmptyMetrics) + { + _raiseOnEmptyMetrics = raiseOnEmptyMetrics; + } + + /// + public void SetCaptureColdStart(bool captureColdStart) + { + _captureColdStartEnabled = captureColdStart; + } + + /// + public Dictionary GetDefaultDimensions() + { + return ListToDictionary(_context.GetDefaultDimensions()); + } + /// /// Serialize global context object /// @@ -277,19 +334,29 @@ public string Serialize() /// 'PushSingleMetric' method requires a valid metrics key. 'Null' or empty /// values are not allowed. /// - void IMetrics.PushSingleMetric(string metricName, double value, MetricUnit unit, string nameSpace, string service, - Dictionary defaultDimensions, MetricResolution metricResolution) + void IMetrics.PushSingleMetric(string metricName, double value, MetricUnit unit, string nameSpace, + string service, Dictionary defaultDimensions, MetricResolution metricResolution) { if (string.IsNullOrWhiteSpace(metricName)) throw new ArgumentNullException(nameof(metricName), "'PushSingleMetric' method requires a valid metrics key. 'Null' or empty values are not allowed."); - using var context = InitializeContext(nameSpace, service, defaultDimensions); + var context = new MetricsContext(); + context.SetNamespace(nameSpace ?? _context.GetNamespace()); + context.SetService(service ?? _context.GetService()); + + if (defaultDimensions != null) + { + var defaultDimensionsList = DictionaryToList(defaultDimensions); + context.SetDefaultDimensions(defaultDimensionsList); + } + context.AddMetric(metricName, value, unit, metricResolution); Flush(context); } + /// /// Implementation of IDisposable interface /// @@ -308,7 +375,7 @@ protected virtual void Dispose(bool disposing) // Cleanup if (disposing) { - _instance.Flush(); + Instance.Flush(); } } @@ -322,7 +389,7 @@ protected virtual void Dispose(bool disposing) public static void AddMetric(string key, double value, MetricUnit unit = MetricUnit.None, MetricResolution metricResolution = MetricResolution.Default) { - _instance.AddMetric(key, value, unit, metricResolution); + Instance.AddMetric(key, value, unit, metricResolution); } /// @@ -331,7 +398,7 @@ public static void AddMetric(string key, double value, MetricUnit unit = MetricU /// Metrics Namespace Identifier public static void SetNamespace(string nameSpace) { - _instance.SetNamespace(nameSpace); + Instance.SetNamespace(nameSpace); } /// @@ -340,7 +407,7 @@ public static void SetNamespace(string nameSpace) /// Namespace identifier public static string GetNamespace() { - return _instance.GetNamespace(); + return Instance.GetNamespace(); } /// @@ -350,7 +417,7 @@ public static string GetNamespace() /// Dimension value public static void AddDimension(string key, string value) { - _instance.AddDimension(key, value); + Instance.AddDimension(key, value); } /// @@ -360,7 +427,7 @@ public static void AddDimension(string key, string value) /// Metadata value public static void AddMetadata(string key, object value) { - _instance.AddMetadata(key, value); + Instance.AddMetadata(key, value); } /// @@ -369,7 +436,15 @@ public static void AddMetadata(string key, object value) /// Default Dimension List public static void SetDefaultDimensions(Dictionary defaultDimensions) { - _instance.SetDefaultDimensions(defaultDimensions); + if (Instance != null) + { + Instance.SetDefaultDimensions(defaultDimensions); + // MetricsDefaults.SetDefaultDimensions(defaultDimensions); + } + else + { + // MetricsDefaults.SetDefaultDimensions(defaultDimensions); + } } /// @@ -377,7 +452,15 @@ public static void SetDefaultDimensions(Dictionary defaultDimens /// public static void ClearDefaultDimensions() { - _instance.ClearDefaultDimensions(); + if (Instance != null) + { + Instance.ClearDefaultDimensions(); + } + else + { + Debug.WriteLine( + $"##WARNING##: Metrics should be initialized in Handler method before calling {nameof(ClearDefaultDimensions)} method."); + } } /// @@ -407,44 +490,16 @@ public static void PushSingleMetric(string metricName, double value, MetricUnit string service = null, Dictionary defaultDimensions = null, MetricResolution metricResolution = MetricResolution.Default) { - _instance.PushSingleMetric(metricName, value, unit, nameSpace, service, defaultDimensions, metricResolution); - } - - /// - /// Sets global namespace, service name and default dimensions list. - /// - /// Metrics namespace - /// Service Name - /// Default Dimensions List - /// MetricsContext. - private MetricsContext InitializeContext(string nameSpace, string service, - Dictionary defaultDimensions) - { - var context = new MetricsContext(); - var defaultDimensionsList = DictionaryToList(defaultDimensions); - - context.SetNamespace(!string.IsNullOrWhiteSpace(nameSpace) - ? nameSpace - : _instance.GetNamespace() ?? _powertoolsConfigurations.MetricsNamespace); - - // this needs to check if service is set through code or env variables - // the default value service_undefined has to be ignored and return null so it is not added as default - // TODO: Check if there is a way to get the default dimensions and if it makes sense - var parsedService = !string.IsNullOrWhiteSpace(service) - ? service - : _powertoolsConfigurations.Service == "service_undefined" - ? null - : _powertoolsConfigurations.Service; - - if (parsedService != null) + if (Instance != null) { - context.SetService(parsedService); - defaultDimensionsList.Add(new DimensionSet("Service", context.GetService())); + Instance.PushSingleMetric(metricName, value, unit, nameSpace, service, defaultDimensions, + metricResolution); + } + else + { + Debug.WriteLine( + $"##WARNING##: Metrics should be initialized in Handler method before calling {nameof(PushSingleMetric)} method."); } - - context.SetDefaultDimensions(defaultDimensionsList); - - return context; } /// @@ -462,6 +517,22 @@ private List DictionaryToList(Dictionary defaultDi return defaultDimensionsList; } + private Dictionary ListToDictionary(List dimensions) + { + var dictionary = new Dictionary(); + try + { + return dimensions != null + ? new Dictionary(dimensions.SelectMany(x => x.Dimensions)) + : dictionary; + } + catch (Exception e) + { + Debug.WriteLine("Error converting list to dictionary: " + e.Message); + return dictionary; + } + } + /// /// Helper method for testing purposes. Clears static instance between test execution /// diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Model/Metadata.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Model/Metadata.cs index ee3d0605..2119dd93 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Model/Metadata.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Model/Metadata.cs @@ -184,4 +184,12 @@ internal void ClearDefaultDimensions() { _metricDirective.ClearDefaultDimensions(); } + + /// + /// Retrieves default dimensions list + /// + internal List GetDefaultDimensions() + { + return _metricDirective.DefaultDimensions; + } } \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricsContext.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricsContext.cs index ba77d0ed..8e886a90 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricsContext.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricsContext.cs @@ -170,4 +170,12 @@ public void ClearDefaultDimensions() { _rootNode.AWS.ClearDefaultDimensions(); } + + /// + /// Retrieves default dimensions list + /// + internal List GetDefaultDimensions() + { + return _rootNode.AWS.GetDefaultDimensions(); + } } \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs index 0be19d3b..d4394a2b 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs @@ -393,6 +393,7 @@ private List AllIndexesOf(string str, string value) public void Dispose() { // need to reset instance after each test + Metrics.ResetForTest(); MetricsAspect.ResetForTest(); Environment.SetEnvironmentVariable("POWERTOOLS_METRICS_NAMESPACE", null); } diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs new file mode 100644 index 00000000..7450f8cd --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using Amazon.Lambda.Core; + +namespace AWS.Lambda.Powertools.Metrics.Tests.Handlers; + +public class DefaultDimensionsHandler +{ + public DefaultDimensionsHandler() + { + Metrics.SetDefaultDimensions(new Dictionary + { + {"Environment", "Prod"}, + {"Another", "One"} + }); + // Metrics.SetNamespace("dotnet-powertools-test"); + // Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count); + } + + [Metrics(Namespace = "dotnet-powertools-test", Service = "testService", CaptureColdStart = true)] + public void Handler() + { + // Default dimensions are already set + Metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + } + + [Metrics(Namespace = "dotnet-powertools-test", Service = "testService", CaptureColdStart = true)] + public void HandlerWithContext(ILambdaContext context) + { + // Default dimensions are already set and adds FunctionName dimension + Metrics.AddMetric("Memory", 10, MetricUnit.Megabytes); + } +} \ 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 3469e2e4..5b6ad783 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs @@ -85,7 +85,7 @@ public void When_LambdaContext_Should_Add_FunctioName_Dimension_CaptureColdStart metricsOutput); Assert.Contains( - "\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"FunctionName\",\"Service\"]]}]}", + "\"CloudWatchMetrics\":[{\"Namespace\":\"ns\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"FunctionName\"]]}]},\"Service\":\"svc\",\"FunctionName\":\"My Function with context\",\"ColdStart\":1}", metricsOutput); } @@ -108,7 +108,7 @@ public void When_LambdaContext_And_Parameter_Should_Add_FunctioName_Dimension_Ca metricsOutput); Assert.Contains( - "\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"FunctionName\",\"Service\"]]}]}", + "\"CloudWatchMetrics\":[{\"Namespace\":\"ns\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"FunctionName\"]]}]},\"Service\":\"svc\",\"FunctionName\":\"My Function with context\",\"ColdStart\":1}", metricsOutput); } @@ -128,10 +128,51 @@ public void When_No_LambdaContext_Should_Not_Add_FunctioName_Dimension_CaptureCo "\"Metrics\":[{\"Name\":\"MyMetric\",\"Unit\":\"None\"}],\"Dimensions\":[[\"Service\"]]}]},\"Service\":\"svc\",\"MyMetric\":1}", metricsOutput); } + + [Fact] + public void DefaultDimensions_AreAppliedCorrectly() + { + // Arrange + var handler = new DefaultDimensionsHandler(); + + // Act + handler.Handler(); + + // 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\":[[\"Environment\",\"Another\",\"Service\"]]}]},\"Environment\":\"Prod\",\"Another\":\"One\",\"Service\":\"testService\",\"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}", metricsOutput); + } + + [Fact] + public void DefaultDimensions_AreAppliedCorrectly_WithContext_FunctionName() + { + // Arrange + var handler = new DefaultDimensionsHandler(); + + // Act + handler.HandlerWithContext(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\":[[\"Environment\",\"Another\",\"Service\",\"FunctionName\"]]}]},\"Environment\":\"Prod\",\"Another\":\"One\",\"Service\":\"testService\",\"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}", metricsOutput); + } public void Dispose() { Metrics.ResetForTest(); MetricsAspect.ResetForTest(); } -} \ No newline at end of file +} + + From 450a2da95934a90d1bfa8fc7d4cde87a1342dc2b Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Wed, 19 Feb 2025 20:24:15 +0000 Subject: [PATCH 2/9] feat(metrics): introduce MetricsOptions for configurable metrics setup and refactor initialization logic --- .../Internal/MetricsAspect.cs | 19 +++++------ .../AWS.Lambda.Powertools.Metrics/Metrics.cs | 30 ++++++++++++++-- .../MetricsOptions.cs | 34 +++++++++++++++++++ .../Handlers/DefaultDimensionsHandler.cs | 21 +++++++++--- 4 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 libraries/src/AWS.Lambda.Powertools.Metrics/MetricsOptions.cs diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs index 4e77cddc..8531d784 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs @@ -14,7 +14,6 @@ */ using System; -using System.Collections.Generic; using System.Linq; using System.Reflection; using Amazon.Lambda.Core; @@ -70,11 +69,12 @@ public void Before( var trigger = triggers.OfType().First(); - _metricsInstance ??= Metrics.Instance; - _metricsInstance.SetService(trigger.Service); - _metricsInstance.SetNamespace(trigger.Namespace); - _metricsInstance.SetRaiseOnEmptyMetrics(trigger.RaiseOnEmptyMetrics); - _metricsInstance.SetCaptureColdStart(trigger.CaptureColdStart); + _metricsInstance ??= Metrics.Configure(options => { + options.Namespace = trigger.Namespace; + options.Service = trigger.Service; + options.RaiseOnEmptyMetrics = trigger.RaiseOnEmptyMetrics; + options.CaptureColdStart = trigger.CaptureColdStart; + }); var defaultDimensions = _metricsInstance.GetDefaultDimensions(); @@ -93,9 +93,6 @@ public void Before( { _isColdStart = false; - var nameSpace = _metricsInstance.GetNamespace(); - var service = _metricsInstance.GetService(); - var context = GetContext(eventArgs); if (context is not null) @@ -108,8 +105,8 @@ public void Before( "ColdStart", 1.0, MetricUnit.Count, - nameSpace, - service, + _metricsInstance.GetNamespace(), + _metricsInstance.GetService(), defaultDimensions ); } diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs index 26c91333..9d45c761 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs @@ -65,6 +65,32 @@ static Metrics() // private readonly object _lockObj = new(); + + /// + /// Initializes a new instance of the class. + /// + /// + /// + public static IMetrics Configure(Action configure) + { + var options = new MetricsOptions(); + configure(options); + + if (!string.IsNullOrEmpty(options.Namespace)) + SetNamespace(options.Namespace); + + if (!string.IsNullOrEmpty(options.Service)) + Instance.SetService(options.Service); + + Instance.SetRaiseOnEmptyMetrics(options.RaiseOnEmptyMetrics); + Instance.SetCaptureColdStart(options.CaptureColdStart); + + if (options.DefaultDimensions != null) + SetDefaultDimensions(options.DefaultDimensions); + + return Instance; + } + /// /// Creates a Metrics object that provides features to send metrics to Amazon Cloudwatch using the Embedded metric /// format (EMF). See @@ -157,7 +183,7 @@ string IMetrics.GetNamespace() { try { - return _context.GetNamespace(); + return _context.GetNamespace() ?? _powertoolsConfigurations.MetricsNamespace; } catch { @@ -342,7 +368,7 @@ void IMetrics.PushSingleMetric(string metricName, double value, MetricUnit unit, "'PushSingleMetric' method requires a valid metrics key. 'Null' or empty values are not allowed."); var context = new MetricsContext(); - context.SetNamespace(nameSpace ?? _context.GetNamespace()); + context.SetNamespace(nameSpace ?? GetNamespace()); context.SetService(service ?? _context.GetService()); if (defaultDimensions != null) diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsOptions.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsOptions.cs new file mode 100644 index 00000000..52292b1d --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsOptions.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace AWS.Lambda.Powertools.Metrics; + +/// +/// Configuration options for AWS Lambda Powertools Metrics. +/// +public class MetricsOptions +{ + /// + /// Gets or sets the CloudWatch metrics namespace. + /// + public string Namespace { get; set; } + + /// + /// Gets or sets the service name to be used as a metric dimension. + /// + public string Service { get; set; } + + /// + /// Gets or sets whether to throw an exception when no metrics are emitted. + /// + public bool RaiseOnEmptyMetrics { get; set; } + + /// + /// Gets or sets whether to capture cold start metrics. + /// + public bool CaptureColdStart { get; set; } + + /// + /// Gets or sets the default dimensions to be added to all metrics. + /// + public Dictionary DefaultDimensions { get; set; } +} \ 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 7450f8cd..1baca918 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs @@ -7,11 +7,24 @@ public class DefaultDimensionsHandler { public DefaultDimensionsHandler() { - Metrics.SetDefaultDimensions(new Dictionary - { - {"Environment", "Prod"}, - {"Another", "One"} + + Metrics.Configure(options => { + // options.Namespace = "my-namespace"; + // options.Service = "my-service"; + // options.CaptureColdStart = true; + // options.RaiseOnEmptyMetrics = true; + options.DefaultDimensions = new Dictionary + { + {"Environment", "Prod"}, + {"Another", "One"} + }; }); + + // Metrics.SetDefaultDimensions(new Dictionary + // { + // {"Environment", "Prod"}, + // {"Another", "One"} + // }); // Metrics.SetNamespace("dotnet-powertools-test"); // Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count); } From df9d4fa418668ddd1bd015c9d80378b2e28787a8 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Thu, 20 Feb 2025 16:34:53 +0000 Subject: [PATCH 3/9] feat(metrics): add MetricsBuilder for fluent configuration of metrics options and enhance default dimensions handling --- .../AWS.Lambda.Powertools.Metrics/IMetrics.cs | 122 +++++------- .../Internal/MetricsAspect.cs | 17 +- .../AWS.Lambda.Powertools.Metrics/Metrics.cs | 181 ++++++------------ .../MetricsAttribute.cs | 30 ++- .../MetricsBuilder.cs | 65 +++++++ .../MetricsOptions.cs | 4 +- .../EMFValidationTests.cs | 2 +- .../Handlers/DefaultDimensionsHandler.cs | 38 +++- .../Handlers/FunctionHandlerTests.cs | 165 ++++++++++++---- .../Handlers/MetricsnBuilderHandler.cs | 29 +++ 10 files changed, 407 insertions(+), 246 deletions(-) create mode 100644 libraries/src/AWS.Lambda.Powertools.Metrics/MetricsBuilder.cs create mode 100644 libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/MetricsnBuilderHandler.cs diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs index 594c9073..69ef2ee3 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs @@ -1,127 +1,109 @@ /* * 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; using System.Collections.Generic; namespace AWS.Lambda.Powertools.Metrics; /// -/// Interface IMetrics -/// Implements the +/// Interface for metrics operations. /// /// -public interface IMetrics +public interface IMetrics { /// - /// Adds metric + /// Adds a metric to the collection. /// - /// Metric key - /// Metric value - /// Metric unit - /// - void AddMetric(string key, double value, MetricUnit unit, MetricResolution metricResolution); + /// The metric key. + /// The metric value. + /// The metric unit. + /// The metric resolution. + void AddMetric(string key, double value, MetricUnit unit = MetricUnit.None, + MetricResolution resolution = MetricResolution.Default); /// - /// Adds a dimension + /// Adds a dimension to the collection. /// - /// Dimension key - /// Dimension value + /// The dimension key. + /// The dimension value. void AddDimension(string key, string value); /// - /// Sets the default dimensions + /// Adds metadata to the collection. /// - /// Default dimensions - void SetDefaultDimensions(Dictionary defaultDimension); - - /// - /// Adds metadata - /// - /// Metadata key - /// Metadata value + /// The metadata key. + /// The metadata value. void AddMetadata(string key, object value); /// - /// Pushes a single metric with custom namespace, service and dimensions. + /// Sets the default dimensions. /// - /// Name of the metric - /// Metric value - /// Metric unit - /// Metric namespace - /// Metric service - /// Metric default dimensions - /// Metrics resolution - void PushSingleMetric(string metricName, double value, MetricUnit unit, string nameSpace = null, - string service = null, Dictionary defaultDimensions = null, MetricResolution metricResolution = MetricResolution.Default); - + /// The default dimensions. + void SetDefaultDimensions(Dictionary defaultDimensions); + /// - /// Sets the namespace + /// Sets the namespace for the metrics. /// - /// Metrics namespace + /// The namespace. void SetNamespace(string nameSpace); /// - /// Gets the namespace + /// Sets the service name for the metrics. /// - /// System.String. - string GetNamespace(); + /// The service name. + void SetService(string service); /// - /// Gets the service + /// Sets whether to raise an event on empty metrics. /// - /// System.String. - string GetService(); + /// If set to true, raises an event on empty metrics. + void SetRaiseOnEmptyMetrics(bool raiseOnEmptyMetrics); /// - /// Serializes metrics instance + /// Sets whether to capture cold start metrics. /// - /// System.String. - string Serialize(); + /// If set to true, captures cold start metrics. + void SetCaptureColdStart(bool captureColdStart); /// - /// Flushes metrics to CloudWatch + /// Pushes a single metric to the collection. /// - /// if set to true [metrics overflow]. - void Flush(bool metricsOverflow = false); - + /// The metric name. + /// The metric value. + /// The metric unit. + /// The namespace. + /// The service name. + /// 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); + /// - /// Clears both default dimensions and dimensions lists + /// Clears the default dimensions. /// void ClearDefaultDimensions(); /// - /// Sets the service + /// Flushes the metrics. /// - /// - void SetService(string triggerService); - - /// - /// Sets the raise on empty metrics - /// - /// - void SetRaiseOnEmptyMetrics(bool triggerRaiseOnEmptyMetrics); - - /// - /// Sets the capture cold start - /// - void SetCaptureColdStart(bool captureColdStart); + /// If set to true, indicates a metrics overflow. + void Flush(bool metricsOverflow = false); /// - /// Gets the default dimensions + /// Gets the metrics options. /// - Dictionary GetDefaultDimensions(); - -} + /// The metrics options. + public MetricsOptions Options { get; } +} \ 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 8531d784..4ebacf14 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs @@ -72,12 +72,10 @@ public void Before( _metricsInstance ??= Metrics.Configure(options => { options.Namespace = trigger.Namespace; options.Service = trigger.Service; - options.RaiseOnEmptyMetrics = trigger.RaiseOnEmptyMetrics; - options.CaptureColdStart = trigger.CaptureColdStart; + options.RaiseOnEmptyMetrics = trigger.IsRaiseOnEmptyMetricsSet ? trigger.RaiseOnEmptyMetrics : null; + options.CaptureColdStart = trigger.IsCaptureColdStartSet ? trigger.CaptureColdStart : null; }); - var defaultDimensions = _metricsInstance.GetDefaultDimensions(); - var eventArgs = new AspectEventArgs { Instance = instance, @@ -89,15 +87,16 @@ public void Before( Triggers = triggers }; - if (trigger.CaptureColdStart && _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) { - defaultDimensions.Add("FunctionName", context.FunctionName); + defaultDimensions?.Add("FunctionName", context.FunctionName); _metricsInstance.SetDefaultDimensions(defaultDimensions); } @@ -105,8 +104,8 @@ public void Before( "ColdStart", 1.0, MetricUnit.Count, - _metricsInstance.GetNamespace(), - _metricsInstance.GetService(), + _metricsInstance.Options?.Namespace ?? "", + _metricsInstance.Options?.Service ?? "", defaultDimensions ); } diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs index 9d45c761..38bf94fc 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.Threading; using AWS.Lambda.Powertools.Common; namespace AWS.Lambda.Powertools.Metrics; @@ -28,17 +29,27 @@ namespace AWS.Lambda.Powertools.Metrics; /// public class Metrics : IMetrics, IDisposable { - static Metrics() + public static IMetrics Instance { - _instance = new Metrics(PowertoolsConfigurations.Instance); + get => Current.Value ?? new Metrics(PowertoolsConfigurations.Instance); + private set => Current.Value = value; } - internal static IMetrics Instance => _instance ?? new Metrics(PowertoolsConfigurations.Instance); + /// + public MetricsOptions Options => + new() + { + CaptureColdStart = _captureColdStartEnabled, + Namespace = GetNamespace(), + Service = GetService(), + RaiseOnEmptyMetrics = _raiseOnEmptyMetrics, + DefaultDimensions = GetDefaultDimensions() + }; /// /// The instance /// - private static IMetrics _instance; + private static readonly AsyncLocal Current = new(); /// /// The context @@ -82,8 +93,10 @@ public static IMetrics Configure(Action configure) if (!string.IsNullOrEmpty(options.Service)) Instance.SetService(options.Service); - Instance.SetRaiseOnEmptyMetrics(options.RaiseOnEmptyMetrics); - Instance.SetCaptureColdStart(options.CaptureColdStart); + if (options.RaiseOnEmptyMetrics.HasValue) + Instance.SetRaiseOnEmptyMetrics(options.RaiseOnEmptyMetrics.Value); + if (options.CaptureColdStart.HasValue) + Instance.SetCaptureColdStart(options.CaptureColdStart.Value); if (options.DefaultDimensions != null) SetDefaultDimensions(options.DefaultDimensions); @@ -104,29 +117,19 @@ public static IMetrics Configure(Action configure) internal Metrics(IPowertoolsConfigurations powertoolsConfigurations, string nameSpace = null, string service = null, bool raiseOnEmptyMetrics = false, bool captureColdStartEnabled = false) { - if (_instance == null) - { - _instance = this; - _powertoolsConfigurations = powertoolsConfigurations; - _raiseOnEmptyMetrics = raiseOnEmptyMetrics; - _captureColdStartEnabled = captureColdStartEnabled; - _context = new MetricsContext(); + _powertoolsConfigurations = powertoolsConfigurations; + _context = new MetricsContext(); + _raiseOnEmptyMetrics = raiseOnEmptyMetrics; + _captureColdStartEnabled = captureColdStartEnabled; - _powertoolsConfigurations.SetExecutionEnvironment(this); - } + Instance = this; + _powertoolsConfigurations.SetExecutionEnvironment(this); + + if (!string.IsNullOrEmpty(nameSpace)) SetNamespace(nameSpace); + if (!string.IsNullOrEmpty(service)) SetService(service); } - /// - /// Implements interface that adds new metric to memory. - /// - /// Metric Key - /// Metric Value - /// Metric Unit - /// Metric resolution - /// - /// 'AddMetric' method requires a valid metrics key. 'Null' or empty values - /// are not allowed. - /// + /// void IMetrics.AddMetric(string key, double value, MetricUnit unit, MetricResolution metricResolution) { if (Instance != null) @@ -164,38 +167,20 @@ void IMetrics.AddMetric(string key, double value, MetricUnit unit, MetricResolut } } - /// - /// Implements interface that sets metrics namespace identifier. - /// - /// Metrics Namespace Identifier + /// void IMetrics.SetNamespace(string nameSpace) { _context.SetNamespace(!string.IsNullOrWhiteSpace(nameSpace) ? nameSpace - : Instance.GetNamespace() ?? _powertoolsConfigurations.MetricsNamespace); + : GetNamespace() ?? _powertoolsConfigurations.MetricsNamespace); } - /// - /// Implements interface that allows retrieval of namespace identifier. - /// - /// Namespace identifier - string IMetrics.GetNamespace() - { - try - { - return _context.GetNamespace() ?? _powertoolsConfigurations.MetricsNamespace; - } - catch - { - return null; - } - } /// /// Implements interface to get service name /// /// System.String. - string IMetrics.GetService() + private string GetService() { try { @@ -207,15 +192,7 @@ string IMetrics.GetService() } } - /// - /// Implements interface that adds a dimension. - /// - /// Dimension key. Must not be null, empty or whitespace - /// Dimension value - /// - /// 'AddDimension' method requires a valid dimension key. 'Null' or empty - /// values are not allowed. - /// + /// void IMetrics.AddDimension(string key, string value) { if (string.IsNullOrWhiteSpace(key)) @@ -225,15 +202,7 @@ void IMetrics.AddDimension(string key, string value) _context.AddDimension(key, value); } - /// - /// Implements interface that adds metadata. - /// - /// Metadata key. Must not be null, empty or whitespace - /// Metadata value - /// - /// 'AddMetadata' method requires a valid metadata key. 'Null' or empty - /// values are not allowed. - /// + /// void IMetrics.AddMetadata(string key, object value) { if (string.IsNullOrWhiteSpace(key)) @@ -243,14 +212,7 @@ void IMetrics.AddMetadata(string key, object value) _context.AddMetadata(key, value); } - /// - /// Implements interface that sets default dimension list - /// - /// Default Dimension List - /// - /// 'SetDefaultDimensions' method requires a valid key pair. 'Null' or empty - /// values are not allowed. - /// + /// void IMetrics.SetDefaultDimensions(Dictionary defaultDimension) { foreach (var item in defaultDimension) @@ -261,12 +223,7 @@ void IMetrics.SetDefaultDimensions(Dictionary defaultDimension) _context.SetDefaultDimensions(DictionaryToList(defaultDimension)); } - /// - /// Flushes metrics in Embedded Metric Format (EMF) to Standard Output. In Lambda, this output is collected - /// automatically and sent to Cloudwatch. - /// - /// If enabled, non-default dimensions are cleared after flushing metrics - /// true + /// void IMetrics.Flush(bool metricsOverflow) { if (_context.GetMetrics().Count == 0 @@ -291,9 +248,7 @@ void IMetrics.Flush(bool metricsOverflow) } } - /// - /// Clears both default dimensions and dimensions lists - /// + /// void IMetrics.ClearDefaultDimensions() { _context.ClearDefaultDimensions(); @@ -314,7 +269,7 @@ public void SetService(string service) { _context.SetService(parsedService); _context.SetDefaultDimensions(new List(new[] - { new DimensionSet("Service", _context.GetService()) })); + { new DimensionSet("Service", GetService()) })); } } @@ -330,36 +285,12 @@ public void SetCaptureColdStart(bool captureColdStart) _captureColdStartEnabled = captureColdStart; } - /// - public Dictionary GetDefaultDimensions() + private Dictionary GetDefaultDimensions() { return ListToDictionary(_context.GetDefaultDimensions()); } - /// - /// Serialize global context object - /// - /// Serialized global context object - public string Serialize() - { - return _context.Serialize(); - } - - /// - /// Implements the interface that 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 Value - /// Metric Unit - /// Metric Namespace - /// Service Name - /// Default dimensions list - /// Metrics resolution - /// - /// 'PushSingleMetric' method requires a valid metrics key. 'Null' or empty - /// values are not allowed. - /// + /// void IMetrics.PushSingleMetric(string metricName, double value, MetricUnit unit, string nameSpace, string service, Dictionary defaultDimensions, MetricResolution metricResolution) { @@ -431,9 +362,16 @@ public static void SetNamespace(string nameSpace) /// Retrieves namespace identifier. /// /// Namespace identifier - public static string GetNamespace() + public string GetNamespace() { - return Instance.GetNamespace(); + try + { + return _context.GetNamespace() ?? _powertoolsConfigurations.MetricsNamespace; + } + catch + { + return null; + } } /// @@ -462,15 +400,7 @@ public static void AddMetadata(string key, object value) /// Default Dimension List public static void SetDefaultDimensions(Dictionary defaultDimensions) { - if (Instance != null) - { - Instance.SetDefaultDimensions(defaultDimensions); - // MetricsDefaults.SetDefaultDimensions(defaultDimensions); - } - else - { - // MetricsDefaults.SetDefaultDimensions(defaultDimensions); - } + Instance?.SetDefaultDimensions(defaultDimensions); } /// @@ -564,6 +494,15 @@ private Dictionary ListToDictionary(List dimension /// internal static void ResetForTest() { - _instance = null; + Instance = null; + } + + /// + /// For testing purposes, resets the Instance to the provided metrics instance. + /// + /// + public static void UseMetricsForTests(IMetrics metricsInstance) + { + Instance = metricsInstance; } } \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsAttribute.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsAttribute.cs index 0033f560..6413b597 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsAttribute.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsAttribute.cs @@ -119,15 +119,41 @@ public class MetricsAttribute : Attribute /// The service. public string Service { get; set; } + private bool _captureColdStartSet; + private bool _captureColdStart; + /// /// Captures cold start during Lambda execution /// /// true if [capture cold start]; otherwise, false. - public bool CaptureColdStart { get; set; } + public bool CaptureColdStart + { + get => _captureColdStart; + set + { + _captureColdStart = value; + _captureColdStartSet = true; + } + } + + internal bool IsCaptureColdStartSet => _captureColdStartSet; + + private bool _raiseOnEmptyMetricsSet; + private bool _raiseOnEmptyMetrics; /// /// Instructs metrics validation to throw exception if no metrics are provided. /// /// true if [raise on empty metrics]; otherwise, false. - public bool RaiseOnEmptyMetrics { get; set; } + public bool RaiseOnEmptyMetrics + { + get => _raiseOnEmptyMetrics; + set + { + _raiseOnEmptyMetrics = value; + _raiseOnEmptyMetricsSet = true; + } + } + + internal bool IsRaiseOnEmptyMetricsSet => _raiseOnEmptyMetricsSet; } diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsBuilder.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsBuilder.cs new file mode 100644 index 00000000..f2d030e7 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsBuilder.cs @@ -0,0 +1,65 @@ +/* + * 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.Collections.Generic; + +namespace AWS.Lambda.Powertools.Metrics; + +public class MetricsBuilder +{ + private readonly MetricsOptions _options = new(); + + public MetricsBuilder WithNamespace(string nameSpace) + { + _options.Namespace = nameSpace; + return this; + } + + public MetricsBuilder WithService(string service) + { + _options.Service = service; + return this; + } + + public MetricsBuilder WithRaiseOnEmptyMetrics(bool raiseOnEmptyMetrics) + { + _options.RaiseOnEmptyMetrics = raiseOnEmptyMetrics; + return this; + } + + public MetricsBuilder WithCaptureColdStart(bool captureColdStart) + { + _options.CaptureColdStart = captureColdStart; + return this; + } + + public MetricsBuilder WithDefaultDimensions(Dictionary defaultDimensions) + { + _options.DefaultDimensions = defaultDimensions; + return this; + } + + public IMetrics Build() + { + return Metrics.Configure(opt => + { + opt.Namespace = _options.Namespace; + opt.Service = _options.Service; + opt.RaiseOnEmptyMetrics = _options.RaiseOnEmptyMetrics; + opt.CaptureColdStart = _options.CaptureColdStart; + opt.DefaultDimensions = _options.DefaultDimensions; + }); + } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsOptions.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsOptions.cs index 52292b1d..67ae87bc 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsOptions.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsOptions.cs @@ -20,12 +20,12 @@ public class MetricsOptions /// /// Gets or sets whether to throw an exception when no metrics are emitted. /// - public bool RaiseOnEmptyMetrics { get; set; } + public bool? RaiseOnEmptyMetrics { get; set; } /// /// Gets or sets whether to capture cold start metrics. /// - public bool CaptureColdStart { get; set; } + public bool? CaptureColdStart { get; set; } /// /// Gets or sets the default dimensions to be added to all metrics. diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs index d4394a2b..adce5337 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs @@ -201,7 +201,7 @@ public void WhenNamespaceIsDefined_AbleToRetrieveNamespace() var metricsOutput = _consoleOut.ToString(); - var result = Metrics.GetNamespace(); + var result = Metrics.Instance.Options.Namespace; // Assert Assert.Equal("dotnet-powertools-test", result); 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 1baca918..426f4147 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs @@ -7,19 +7,19 @@ public class DefaultDimensionsHandler { public DefaultDimensionsHandler() { - - Metrics.Configure(options => { + Metrics.Configure(options => + { // options.Namespace = "my-namespace"; // options.Service = "my-service"; // options.CaptureColdStart = true; // options.RaiseOnEmptyMetrics = true; options.DefaultDimensions = new Dictionary { - {"Environment", "Prod"}, - {"Another", "One"} + { "Environment", "Prod" }, + { "Another", "One" } }; }); - + // Metrics.SetDefaultDimensions(new Dictionary // { // {"Environment", "Prod"}, @@ -28,18 +28,42 @@ public DefaultDimensionsHandler() // Metrics.SetNamespace("dotnet-powertools-test"); // Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count); } - + [Metrics(Namespace = "dotnet-powertools-test", Service = "testService", CaptureColdStart = true)] public void Handler() { // Default dimensions are already set Metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); } - + [Metrics(Namespace = "dotnet-powertools-test", Service = "testService", CaptureColdStart = true)] public void HandlerWithContext(ILambdaContext context) { // Default dimensions are already set and adds FunctionName dimension Metrics.AddMetric("Memory", 10, MetricUnit.Megabytes); } +} + +public class MetricsDependencyInjectionOptionsHandler +{ + private readonly IMetrics _metrics; + + // Allow injection of IMetrics for testing + public MetricsDependencyInjectionOptionsHandler(IMetrics metrics = null) + { + _metrics = metrics ?? Metrics.Configure(options => + { + options.DefaultDimensions = new Dictionary + { + { "Environment", "Prod" }, + { "Another", "One" } + }; + }); + } + + [Metrics(Namespace = "dotnet-powertools-test", Service = "testService", CaptureColdStart = true)] + public void Handler() + { + _metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + } } \ 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 5b6ad783..36a5818e 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs @@ -14,9 +14,11 @@ */ using System; +using System.Collections.Generic; using System.Threading.Tasks; using Amazon.Lambda.TestUtilities; using AWS.Lambda.Powertools.Common; +using NSubstitute; using Xunit; namespace AWS.Lambda.Powertools.Metrics.Tests.Handlers; @@ -26,38 +28,38 @@ public class FunctionHandlerTests : IDisposable { private readonly FunctionHandler _handler; private readonly CustomConsoleWriter _consoleOut; - + public FunctionHandlerTests() { _handler = new FunctionHandler(); _consoleOut = new CustomConsoleWriter(); SystemWrapper.Instance.SetOut(_consoleOut); } - + [Fact] public async Task When_Metrics_Add_Metadata_Same_Key_Should_Ignore_Metadata() { // Arrange - - + + // Act - var exception = await Record.ExceptionAsync( () => _handler.HandleSameKey("whatever")); - + var exception = await Record.ExceptionAsync(() => _handler.HandleSameKey("whatever")); + // Assert Assert.Null(exception); } - + [Fact] public async Task When_Metrics_Add_Metadata_Second_Invocation_Should_Not_Throw_Exception() { // Act - var exception = await Record.ExceptionAsync( () => _handler.HandleTestSecondCall("whatever")); + var exception = await Record.ExceptionAsync(() => _handler.HandleTestSecondCall("whatever")); Assert.Null(exception); - - exception = await Record.ExceptionAsync( () => _handler.HandleTestSecondCall("whatever")); + + exception = await Record.ExceptionAsync(() => _handler.HandleTestSecondCall("whatever")); Assert.Null(exception); } - + [Fact] public async Task When_Metrics_Add_Metadata_FromMultipleThread_Should_Not_Throw_Exception() { @@ -65,7 +67,7 @@ public async Task When_Metrics_Add_Metadata_FromMultipleThread_Should_Not_Throw_ var exception = await Record.ExceptionAsync(() => _handler.HandleMultipleThreads("whatever")); Assert.Null(exception); } - + [Fact] public void When_LambdaContext_Should_Add_FunctioName_Dimension_CaptureColdStart() { @@ -74,21 +76,21 @@ public void When_LambdaContext_Should_Add_FunctioName_Dimension_CaptureColdStart { FunctionName = "My Function with context" }; - + // Act _handler.HandleWithLambdaContext(context); var metricsOutput = _consoleOut.ToString(); - + // Assert Assert.Contains( "\"FunctionName\":\"My Function with context\"", metricsOutput); - + Assert.Contains( "\"CloudWatchMetrics\":[{\"Namespace\":\"ns\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"FunctionName\"]]}]},\"Service\":\"svc\",\"FunctionName\":\"My Function with context\",\"ColdStart\":1}", metricsOutput); } - + [Fact] public void When_LambdaContext_And_Parameter_Should_Add_FunctioName_Dimension_CaptureColdStart() { @@ -97,38 +99,38 @@ public void When_LambdaContext_And_Parameter_Should_Add_FunctioName_Dimension_Ca { FunctionName = "My Function with context" }; - + // Act - _handler.HandleWithParamAndLambdaContext("Hello",context); + _handler.HandleWithParamAndLambdaContext("Hello", context); var metricsOutput = _consoleOut.ToString(); - + // Assert Assert.Contains( "\"FunctionName\":\"My Function with context\"", metricsOutput); - + Assert.Contains( "\"CloudWatchMetrics\":[{\"Namespace\":\"ns\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"FunctionName\"]]}]},\"Service\":\"svc\",\"FunctionName\":\"My Function with context\",\"ColdStart\":1}", metricsOutput); } - + [Fact] public void When_No_LambdaContext_Should_Not_Add_FunctioName_Dimension_CaptureColdStart() { // Act _handler.HandleColdStartNoContext(); var metricsOutput = _consoleOut.ToString(); - + // Assert Assert.DoesNotContain( "\"FunctionName\"", metricsOutput); - + Assert.Contains( "\"Metrics\":[{\"Name\":\"MyMetric\",\"Unit\":\"None\"}],\"Dimensions\":[[\"Service\"]]}]},\"Service\":\"svc\",\"MyMetric\":1}", metricsOutput); } - + [Fact] public void DefaultDimensions_AreAppliedCorrectly() { @@ -140,13 +142,17 @@ public void DefaultDimensions_AreAppliedCorrectly() // 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\":[[\"Environment\",\"Another\",\"Service\"]]}]},\"Environment\":\"Prod\",\"Another\":\"One\",\"Service\":\"testService\",\"ColdStart\":1}", metricsOutput); + Assert.Contains( + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Environment\",\"Another\",\"Service\"]]}]},\"Environment\":\"Prod\",\"Another\":\"One\",\"Service\":\"testService\",\"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}", metricsOutput); + Assert.Contains( + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"SuccessfulBooking\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Environment\",\"Another\",\"Service\"]]}]},\"Environment\":\"Prod\",\"Another\":\"One\",\"Service\":\"testService\",\"SuccessfulBooking\":1}", + metricsOutput); } - + [Fact] public void DefaultDimensions_AreAppliedCorrectly_WithContext_FunctionName() { @@ -161,11 +167,104 @@ public void DefaultDimensions_AreAppliedCorrectly_WithContext_FunctionName() // 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\":[[\"Environment\",\"Another\",\"Service\",\"FunctionName\"]]}]},\"Environment\":\"Prod\",\"Another\":\"One\",\"Service\":\"testService\",\"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}", + metricsOutput); + } + + [Fact] + public void Handler_WithMockedMetrics_ShouldCallAddMetric() + { + // Arrange + 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 MetricsDependencyInjectionOptionsHandler(metricsMock); + + // Act + sut.Handler(); + + // Assert + metricsMock.Received(1).PushSingleMetric("ColdStart", 1, MetricUnit.Count, "dotnet-powertools-test", + service: "testService", Arg.Any>()); + metricsMock.Received(1).AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + } + + [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\":[[\"Environment\",\"Another\",\"Service\",\"FunctionName\"]]}]},\"Environment\":\"Prod\",\"Another\":\"One\",\"Service\":\"testService\",\"FunctionName\":\"My_Function_Name\",\"ColdStart\":1}", metricsOutput); + 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\":\"Memory\",\"Unit\":\"Megabytes\"}],\"Dimensions\":[[\"Environment\",\"Another\",\"Service\",\"FunctionName\"]]}]},\"Environment\":\"Prod\",\"Another\":\"One\",\"Service\":\"testService\",\"FunctionName\":\"My_Function_Name\",\"Memory\":10}", metricsOutput); + 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); } public void Dispose() @@ -173,6 +272,4 @@ public void Dispose() Metrics.ResetForTest(); MetricsAspect.ResetForTest(); } -} - - +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/MetricsnBuilderHandler.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/MetricsnBuilderHandler.cs new file mode 100644 index 00000000..f9fd329f --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/MetricsnBuilderHandler.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using Amazon.Lambda.Core; + +namespace AWS.Lambda.Powertools.Metrics.Tests.Handlers; + +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); + } +} \ No newline at end of file From e8ef8dd12454a145ac3cd9747b47abaa82d6a20a Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Thu, 20 Feb 2025 16:47:01 +0000 Subject: [PATCH 4/9] feat(metrics): enhance MetricsBuilder with detailed configuration options and improve documentation --- .../AWS.Lambda.Powertools.Metrics/Metrics.cs | 3 ++ .../MetricsBuilder.cs | 32 +++++++++++++++++++ .../Handlers/DefaultDimensionsHandler.cs | 12 ------- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs index 38bf94fc..c6c0e3be 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs @@ -29,6 +29,9 @@ namespace AWS.Lambda.Powertools.Metrics; /// public class Metrics : IMetrics, IDisposable { + /// + /// Gets or sets the instance. + /// public static IMetrics Instance { get => Current.Value ?? new Metrics(PowertoolsConfigurations.Instance); diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsBuilder.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsBuilder.cs index f2d030e7..ad5b516a 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsBuilder.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsBuilder.cs @@ -17,40 +17,72 @@ namespace AWS.Lambda.Powertools.Metrics; +/// +/// Provides a builder for configuring metrics. +/// public class MetricsBuilder { private readonly MetricsOptions _options = new(); + /// + /// Sets the namespace for the metrics. + /// + /// The namespace identifier. + /// The current instance of . public MetricsBuilder WithNamespace(string nameSpace) { _options.Namespace = nameSpace; return this; } + /// + /// Sets the service name for the metrics. + /// + /// The service name. + /// The current instance of . public MetricsBuilder WithService(string service) { _options.Service = service; return this; } + /// + /// Sets whether to raise an exception if no metrics are captured. + /// + /// If true, raises an exception when no metrics are captured. + /// The current instance of . public MetricsBuilder WithRaiseOnEmptyMetrics(bool raiseOnEmptyMetrics) { _options.RaiseOnEmptyMetrics = raiseOnEmptyMetrics; return this; } + /// + /// Sets whether to capture cold start metrics. + /// + /// If true, captures cold start metrics. + /// The current instance of . public MetricsBuilder WithCaptureColdStart(bool captureColdStart) { _options.CaptureColdStart = captureColdStart; return this; } + /// + /// Sets the default dimensions for the metrics. + /// + /// A dictionary of default dimensions. + /// The current instance of . public MetricsBuilder WithDefaultDimensions(Dictionary defaultDimensions) { _options.DefaultDimensions = defaultDimensions; return this; } + /// + /// Builds and configures the metrics instance. + /// + /// An instance of . public IMetrics Build() { return Metrics.Configure(opt => 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 426f4147..1028f58c 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs @@ -9,24 +9,12 @@ public DefaultDimensionsHandler() { Metrics.Configure(options => { - // options.Namespace = "my-namespace"; - // options.Service = "my-service"; - // options.CaptureColdStart = true; - // options.RaiseOnEmptyMetrics = true; options.DefaultDimensions = new Dictionary { { "Environment", "Prod" }, { "Another", "One" } }; }); - - // Metrics.SetDefaultDimensions(new Dictionary - // { - // {"Environment", "Prod"}, - // {"Another", "One"} - // }); - // Metrics.SetNamespace("dotnet-powertools-test"); - // Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count); } [Metrics(Namespace = "dotnet-powertools-test", Service = "testService", CaptureColdStart = true)] From 5c4d1995b80904c62766c9bab285b8967ba30d82 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Tue, 25 Feb 2025 13:05:35 +0000 Subject: [PATCH 5/9] refactor(metrics): standardize parameter names for clarity in metric methods --- .../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 7f7b1153cfe83363294fe0c47d1a0921ce9c9441 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Tue, 25 Feb 2025 14:56:35 +0000 Subject: [PATCH 6/9] feat(metrics): add HandlerRaiseOnEmptyMetrics method and corresponding test for empty metrics exception --- .../Handlers/FunctionHandler.cs | 6 ++++++ .../Handlers/FunctionHandlerTests.cs | 11 ++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) 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..abc41d7f 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs @@ -214,4 +214,10 @@ public void HandleWithParamAndLambdaContext(string input, ILambdaContext context { } + + [Metrics(Namespace = "ns", Service = "svc", RaiseOnEmptyMetrics = true)] + public void HandlerRaiseOnEmptyMetrics() + { + + } } \ 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 36a5818e..c77319f4 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs @@ -39,9 +39,6 @@ public FunctionHandlerTests() [Fact] public async Task When_Metrics_Add_Metadata_Same_Key_Should_Ignore_Metadata() { - // Arrange - - // Act var exception = await Record.ExceptionAsync(() => _handler.HandleSameKey("whatever")); @@ -266,6 +263,14 @@ 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 When_RaiseOnEmptyMetrics_And_NoMetrics_Should_ThrowException() + { + // Act & Assert + var exception = Assert.Throws(() => _handler.HandlerRaiseOnEmptyMetrics()); + Assert.Equal("No metrics have been provided.", exception.Message); + } public void Dispose() { From 3c80a2d6d7e3ee6aaec5a7a53738c80be46b4532 Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:26:02 +0000 Subject: [PATCH 7/9] feat(metrics): add HandlerEmpty method and test for empty metrics exception handling --- .../Handlers/FunctionHandlerTests.cs | 11 +++++++++++ .../Handlers/MetricsnBuilderHandler.cs | 6 ++++++ 2 files changed, 17 insertions(+) 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 c77319f4..dd2fe7e8 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs @@ -271,6 +271,17 @@ public void When_RaiseOnEmptyMetrics_And_NoMetrics_Should_ThrowException() var exception = Assert.Throws(() => _handler.HandlerRaiseOnEmptyMetrics()); Assert.Equal("No metrics have been provided.", exception.Message); } + + [Fact] + public void Handler_With_Builder_Should_Raise_Empty_Metrics() + { + // Arrange + var handler = new MetricsnBuilderHandler(); + + // Act & Assert + var exception = Assert.Throws(() => handler.HandlerEmpty()); + Assert.Equal("No metrics have been provided.", exception.Message); + } 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..83cc0e89 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/MetricsnBuilderHandler.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/MetricsnBuilderHandler.cs @@ -14,6 +14,7 @@ public MetricsnBuilderHandler(IMetrics metrics = null) .WithCaptureColdStart(true) .WithService("testService") .WithNamespace("dotnet-powertools-test") + .WithRaiseOnEmptyMetrics(true) .WithDefaultDimensions(new Dictionary { { "Environment", "Prod1" }, @@ -26,4 +27,9 @@ public void Handler(ILambdaContext context) { _metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); } + + [Metrics] + public void HandlerEmpty() + { + } } \ No newline at end of file From b9ba2ce7563c8a0f1e0bb145cf92b3669248c5bd Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:33:35 +0000 Subject: [PATCH 8/9] feat(metrics): enhance cold start handling with default dimensions and add corresponding tests --- .../Internal/MetricsAspect.cs | 4 +- .../Handlers/FunctionHandlerTests.cs | 83 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs index 4ebacf14..1006d25c 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; @@ -96,7 +97,8 @@ public void Before( if (context is not null) { - defaultDimensions?.Add("FunctionName", context.FunctionName); + defaultDimensions ??= new Dictionary(); + defaultDimensions.Add("FunctionName", context.FunctionName); _metricsInstance.SetDefaultDimensions(defaultDimensions); } 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 dd2fe7e8..c34397f4 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs @@ -282,6 +282,89 @@ 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() { From 518b87427107d3a741bd69007fac1df1fa4f518b Mon Sep 17 00:00:00 2001 From: Henrique <999396+hjgraca@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:49:27 +0000 Subject: [PATCH 9/9] feat(metrics): add unit tests for Metrics constructor and validation methods --- .../MetricsTests.cs | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs index 97aa5bf8..13afdecd 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using AWS.Lambda.Powertools.Common; using NSubstitute; using Xunit; @@ -30,4 +32,122 @@ public void Metrics_Set_Execution_Environment_Context() env.Received(1).GetEnvironmentVariable("AWS_EXECUTION_ENV"); } + + [Fact] + public void When_Constructor_With_Namespace_And_Service_Should_Set_Both() + { + // Arrange + var metricsMock = 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() + { + // Arrange + var metricsMock = Substitute.For(); + var powertoolsConfigMock = Substitute.For(); + powertoolsConfigMock.MetricsNamespace.Returns((string)null); + powertoolsConfigMock.Service.Returns("service_undefined"); + + // Act + var metrics = new Metrics(powertoolsConfigMock, null, null); + + // Assert + Assert.Null(metrics.GetNamespace()); + Assert.Null(metrics.Options.Service); + } + + [Fact] + public void When_AddMetric_With_EmptyKey_Should_ThrowArgumentNullException() + { + // Arrange + var metricsMock = Substitute.For(); + var powertoolsConfigMock = Substitute.For(); + IMetrics metrics = new Metrics(powertoolsConfigMock); + + // 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); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void When_AddMetric_With_InvalidKey_Should_ThrowArgumentNullException(string key) + { + // Arrange + // var metricsMock = Substitute.For(); + var powertoolsConfigMock = Substitute.For(); + IMetrics metrics = new Metrics(powertoolsConfigMock); + + // 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); + } + + [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 + { " ", "value" }, // whitespace key + { "key1", " " }, // whitespace value + { "key2", null } // null value + }; + + // Act & Assert + foreach (var dimension in invalidDimensions) + { + 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); + } + } + + [Fact] + public void When_PushSingleMetric_With_EmptyName_Should_ThrowArgumentNullException() + { + // Arrange + var powertoolsConfigMock = Substitute.For(); + IMetrics metrics = new Metrics(powertoolsConfigMock); + + // 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); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void When_PushSingleMetric_With_InvalidName_Should_ThrowArgumentNullException(string name) + { + // Arrange + var powertoolsConfigMock = Substitute.For(); + IMetrics metrics = new Metrics(powertoolsConfigMock); + + // Act & Assert + 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); + } } \ No newline at end of file